//! The advanced URI surface: the `notescraft://` links and the `uri.resolve` //! command. mod common; use common::{seed, temp_vault}; use notescraft_engine::uri::{SCHEME, UriAction, format_open_uri, parse}; use notescraft_engine::{Error, Registry, Session, register_core}; use serde_json::{Value, json}; /// The vault name of a temp vault: the folder name, as the UI shows it. fn vault_name(vault: &common::TempVault) -> String { vault.path().file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default() } /// A registry with the core commands and a session on a small vault. fn fixture(name: &str) -> (Registry, Session, common::TempVault) { let vault = temp_vault(name); seed(vault.path(), &[("root.md", "# Root\n"), ("notes/Child.md", "# Child\n")]); let mut reg = Registry::new(); register_core(&mut reg).expect("register the core commands"); let session = Session::open(vault.path()).expect("open the vault"); (reg, session, vault) } /// The error code a command reported. fn code(result: &Result) -> String { match result { Err(Error::Command { source, .. }) => source.code.clone(), other => panic!("expected a command error, got {other:?}"), } } /// Every action of the grammar is parsed. #[test] fn every_action_is_parsed() { let open = parse("notescraft://open?vault=My%20Vault&file=notes/Child.md&heading=Child&block=%5Eabc&line=12").expect("open"); assert_eq!( open, UriAction::Open { vault: "My Vault".to_owned(), file: "notes/Child.md".to_owned(), heading: Some("Child".to_owned()), block: Some("^abc".to_owned()), line: Some(12), } ); let new = parse("notescraft://new?vault=V&name=Inbox/New.md&content=Hello%20world").expect("new"); assert_eq!(new, UriAction::New { vault: "V".to_owned(), name: "Inbox/New.md".to_owned(), content: "Hello world".to_owned() }); let daily = parse("notescraft://daily?vault=V").expect("daily"); assert_eq!(daily, UriAction::Daily { vault: "V".to_owned() }); let search = parse("notescraft://search?vault=V&query=rust").expect("search"); assert_eq!(search, UriAction::Search { vault: "V".to_owned(), query: "rust".to_owned() }); } /// Percent-encoded UTF-8 comes back as it was written. #[test] fn percent_encoded_utf8_is_decoded() { let action = parse("notescraft://open?vault=Notes&file=Di%C3%A1rios%2FA%C3%A7%C3%A3o.md").expect("open"); assert_eq!( action, UriAction::Open { vault: "Notes".to_owned(), file: "Di\u{e1}rios/A\u{e7}\u{e3}o.md".to_owned(), heading: None, block: None, line: None } ); let search = parse("notescraft://search?vault=Notes&query=a+b").expect("search"); assert_eq!(search, UriAction::Search { vault: "Notes".to_owned(), query: "a+b".to_owned() }, "a plus is not a space"); } /// The paths a link may not carry are refused. #[test] fn hostile_paths_are_refused() { for url in [ "notescraft://open?vault=V&file=../escape.md", "notescraft://open?vault=V&file=a/../../escape.md", "notescraft://open?vault=V&file=/etc/passwd.md", "notescraft://open?vault=V&file=.obsidian/app.md", "notescraft://open?vault=V&file=.notescraft/x.md", "notescraft://new?vault=V&name=.notescraft/x.md", "notescraft://open?vault=V&file=root%00.md", "notescraft://open?vault=V&file=root.txt", ] { let error = parse(url).expect_err("the link must be refused"); assert_eq!(error.code, "invalid_params", "{url}"); } } /// A link over the cap is refused before it is looked at. #[test] fn a_long_link_is_refused() { let url = format!("notescraft://open?vault=V&file={}.md", "a".repeat(5000)); let error = parse(&url).expect_err("the link must be refused"); assert_eq!(error.code, "invalid_params"); } /// Only the four actions are supported; a link never runs a command. #[test] fn an_unknown_action_is_refused() { for url in ["notescraft://delete?vault=V", "notescraft://", "notescraft://open?vault=V&file=a.md&typo=1", "https://example.com"] { assert!(parse(url).is_err(), "{url}"); } let error = parse("notescraft://command?id=note.write").expect_err("a link never runs a command"); assert_eq!(error.code, "invalid_params"); assert!(error.message.contains("command"), "{}", error.message); } /// The command reports the note the link points at. #[test] fn the_resolve_command_reports_the_note() { let (reg, mut session, vault) = fixture("resolve"); let name = vault_name(&vault); let resolved = reg.run("uri.resolve", &mut session, &json!({ "url": format_open_uri(&name, "root.md", None) })).expect("uri.resolve"); assert_eq!(resolved.get("action"), Some(&json!("open"))); assert_eq!(resolved.get("vault"), Some(&json!(name))); assert_eq!(resolved.get("path"), Some(&json!("root.md"))); assert_eq!(resolved.get("exists"), Some(&json!(true))); assert_eq!(resolved.get("needs_confirmation"), Some(&json!(false))); let missing = reg.run("uri.resolve", &mut session, &json!({ "url": format_open_uri(&name, "nope.md", None) })).expect("uri.resolve"); assert_eq!(missing.get("exists"), Some(&json!(false))); } /// A vault that is not open is not found. #[test] fn an_unknown_vault_is_not_found() { let (reg, mut session, _vault) = fixture("unknown-vault"); let result = reg.run("uri.resolve", &mut session, &json!({ "url": "notescraft://open?vault=other&file=root.md" })); assert_eq!(code(&result), "not_found"); } /// A link that would create a note asks first, and writes nothing. #[test] fn a_new_link_asks_for_confirmation() { let (reg, mut session, vault) = fixture("new-link"); let name = vault_name(&vault); let url = format!("{SCHEME}new?vault={name}&name=Inbox/New.md&content=Hello"); let resolved = reg.run("uri.resolve", &mut session, &json!({ "url": url })).expect("uri.resolve"); assert_eq!(resolved.get("action"), Some(&json!("new"))); assert_eq!(resolved.get("path"), Some(&json!("Inbox/New.md"))); assert_eq!(resolved.get("needs_confirmation"), Some(&json!(true))); assert_eq!(resolved.get("content"), Some(&json!("Hello"))); assert!(!vault.path().join("Inbox/New.md").exists(), "the link wrote nothing"); } /// A daily link reports the note of today. #[test] fn a_daily_link_reports_todays_note() { let (reg, mut session, vault) = fixture("daily"); let name = vault_name(&vault); let resolved = reg.run("uri.resolve", &mut session, &json!({ "url": format!("{SCHEME}daily?vault={name}") })).expect("uri.resolve"); assert_eq!(resolved.get("action"), Some(&json!("daily"))); let path = resolved.get("path").and_then(Value::as_str).unwrap_or_default(); assert!(path.starts_with("Daily/"), "{path}"); assert!(path.to_lowercase().ends_with(".md"), "{path}"); } /// The helper the UI copies round trips through the parser. #[test] fn the_format_helper_round_trips() { let url = format_open_uri("My Vault", "a/b c.md", Some("Head ing")); assert_eq!(url, "notescraft://open?vault=My%20Vault&file=a%2Fb%20c.md&heading=Head%20ing"); assert_eq!( parse(&url).expect("the link"), UriAction::Open { vault: "My Vault".to_owned(), file: "a/b c.md".to_owned(), heading: Some("Head ing".to_owned()), block: None, line: None } ); assert_eq!(format_open_uri("V", "a.md", None), "notescraft://open?vault=V&file=a.md"); }