onenote-cli 0.1.0

Read and capture Microsoft OneNote notes through its Windows desktop application
Documentation
use assert_cmd::Command;
use clap::{CommandFactory, Parser};
use onenote_cli::{
    cli::{Cli, Format, PageArgs},
    model, output, schema,
};
use serde_json::Value;

const HIERARCHY: &str = include_str!("fixtures/hierarchy.xml");
fn args(limit: u16, offset: u32) -> PageArgs {
    PageArgs {
        limit,
        offset,
        fields: vec![],
    }
}

#[test]
fn nested_sections_pagination_projection_and_empty_results() {
    let page = model::collection(HIERARCHY, "Section", &args(1, 0)).unwrap();
    assert_eq!(page["total"], 2);
    assert_eq!(page["next_offset"], 1);
    assert_eq!(page["items"][0]["path"], "Work / Meetings");
    let next = model::collection(HIERARCHY, "Section", &args(1, 1)).unwrap();
    assert_eq!(next["items"][0]["path"], "Work / Projects / Planning");
    assert_eq!(next["items"][0]["locked"], true);
    assert_eq!(next["items"][0]["parent_id"], "{group}");
    assert!(next["next_offset"].is_null());
    let empty = model::collection(HIERARCHY, "Page", &args(10, 20)).unwrap();
    assert_eq!(empty["items"], serde_json::json!([]));
    assert_eq!(empty["truncated"], false);
    let projected = model::collection(
        HIERARCHY,
        "Page",
        &PageArgs {
            fields: vec!["id".into()],
            ..args(10, 0)
        },
    )
    .unwrap();
    assert_eq!(projected["items"][0].as_object().unwrap().len(), 1);
    assert!(
        model::collection(
            HIERARCHY,
            "Page",
            &PageArgs {
                fields: vec!["typo".into()],
                ..args(10, 0)
            }
        )
        .is_err()
    );
}
#[test]
fn search_never_treats_unindexed_pages_as_matches() {
    let xml = r#"<one:Section xmlns:one="http://schemas.microsoft.com/office/onenote/2013/onenote" ID="section" name="Test">
        <one:Page ID="pending" name="Not a match" isIndexed="false"/>
        <one:Page ID="match" name="Match" isIndexed="true"/>
        <one:Page ID="match2" name="Another match"/>
    </one:Section>"#;
    let first = model::search(xml, &args(1, 0)).unwrap();
    assert_eq!(first["items"][0]["id"], "match");
    assert_eq!(first["total"], 2);
    assert_eq!(first["next_offset"], 1);
    assert_eq!(first["unindexed_count"], 1);
    assert_eq!(first["indexing_pending"], true);
    assert!(output::render(&first, false).contains("unindexed pages omitted"));
    assert_eq!(
        model::search(xml, &args(1, 1)).unwrap()["items"][0]["id"],
        "match2"
    );
    assert_eq!(
        model::collection(xml, "Page", &args(10, 0)).unwrap()["total"],
        3
    );
    let only_pending = xml
        .replace("isIndexed=\"true\"", "isIndexed=\"false\"")
        .replace(
            "name=\"Another match\"",
            "name=\"Another match\" isIndexed=\"false\"",
        );
    let empty = model::search(&only_pending, &args(10, 0)).unwrap();
    assert_eq!(empty["total"], 0);
    assert_eq!(empty["unindexed_count"], 3);
    assert_eq!(empty["truncated"], false);
    assert!(empty["next_offset"].is_null());
}

#[test]
fn page_preserves_unicode_links_and_exposes_nontext_content() {
    let xml = include_str!("fixtures/page.xml");
    let page = model::read(xml, "{page1}", true).unwrap();
    let markdown = page["markdown"].as_str().unwrap();
    assert!(markdown.contains("**team**"));
    assert!(markdown.contains("& 日本語"));
    assert!(markdown.contains("[Report](https://example.com/report)"));
    assert!(!markdown.contains("Quarterly review"));
    assert_eq!(page["has_images"], true);
    assert_eq!(page["has_attachments"], true);
    assert_eq!(page["xml"], xml);
    assert!(model::read(xml, "other", false).is_err());
    assert!(
        model::read(xml, "{page1}", false)
            .unwrap()
            .get("xml")
            .is_none()
    );
}
#[test]
fn rejects_malformed_xml_and_dtds() {
    for xml in [
        "garbage",
        "<Page/>",
        "<!DOCTYPE x [<!ENTITY leak SYSTEM 'file:///etc/passwd'>]><x>&leak;</x>",
    ] {
        assert!(model::collection(xml, "Page", &args(10, 0)).is_err());
    }
}
#[test]
fn contract_is_valid_and_matches_all_invocable_commands() {
    Cli::command().debug_assert();
    let spec: Value = serde_json::from_str(include_str!("fixtures/clispec-v0.3.json")).unwrap();
    let contract = schema::generate(None);
    jsonschema::validator_for(&spec)
        .unwrap()
        .validate(&contract)
        .unwrap();
    fn paths(command: clap::Command, prefix: String) -> Vec<String> {
        command
            .get_subcommands()
            .flat_map(|child| {
                let name = format!("{prefix}{}", child.get_name());
                if child.has_subcommands() {
                    paths(child.clone(), format!("{name} "))
                } else {
                    vec![name]
                }
            })
            .collect()
    }
    let mut actual = paths(Cli::command(), String::new());
    let mut declared: Vec<_> = contract["commands"]
        .as_array()
        .unwrap()
        .iter()
        .map(|c| c["name"].as_str().unwrap().to_owned())
        .collect();
    actual.sort();
    declared.sort();
    assert_eq!(actual, declared);
}
#[test]
fn terminal_output_keeps_ids_and_continuation_and_sanitizes_controls() {
    let page = model::collection(HIERARCHY, "Page", &args(1, 0)).unwrap();
    let text = output::render(&page, false);
    assert!(text.contains("Quarterly review"));
    assert!(text.contains("{page1}"));
    assert!(text.contains("--offset 1"));
    assert!(!text.contains('\x1b'));
    assert_eq!(
        output::clean("hello\x1b\u{009b}\u{202e}\rworld\n"),
        "helloworld\n"
    );
}
#[test]
fn parse_errors_respect_each_output_spelling_and_leave_stdout_empty() {
    for flag in [
        vec!["-o", "json"],
        vec!["-ojson"],
        vec!["-o=json"],
        vec!["--output=json"],
    ] {
        let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("onenote"));
        let result = cmd
            .args(flag)
            .args(["pages", "list", "--limit", "0"])
            .assert()
            .code(2);
        let output = result.get_output();
        assert!(output.stdout.is_empty());
        let error: Value = serde_json::from_slice(&output.stderr).unwrap();
        assert_eq!(error["error"]["kind"], "invalid_input");
    }
    let args = ["onenote", "-otext", "--", "-ojson"].map(str::to_owned);
    assert_eq!(output::format_from_args(&args), Format::Text);
    assert!(Cli::try_parse_from(["onenote", "search", "   "]).is_err());
    assert!(Cli::try_parse_from(["onenote", "pages", "read", ""]).is_err());
}
#[test]
fn offline_commands_work_without_desktop_and_bad_fields_fail_before_launch() {
    for command in [
        vec!["schema"],
        vec!["capabilities"],
        vec!["doctor", "--offline"],
    ] {
        let result = Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
            .args(command)
            .assert()
            .success();
        serde_json::from_slice::<Value>(&result.get_output().stdout).unwrap();
    }
    Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
        .args(["notebooks", "list", "--fields", "typo"])
        .assert()
        .code(2);
    Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
        .args(["schema", "--command", "missing"])
        .assert()
        .code(4);
}

#[test]
fn write_previews_validate_content_without_a_desktop_connection() {
    for operation in ["create", "append"] {
        let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("onenote"));
        cmd.args(["pages", operation]);
        if operation == "create" {
            cmd.args(["--section", "section", "--title", "Notes <&> 日本語"]);
        } else {
            cmd.arg("page");
        }
        let result = cmd
            .args([
                "--text",
                "Literal <script> & $(exit) ]]> 日本語",
                "--dry-run",
                "-ojson",
            ])
            .assert()
            .success();
        let value: Value = serde_json::from_slice(&result.get_output().stdout).unwrap();
        assert_eq!(value["dry_run"], true);
        assert_eq!(value["operation"], operation);
        assert!(value["text"].as_str().unwrap().contains("<script>"));
    }
    let result = Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
        .args(["pages", "append", "page", "--file", "-", "--dry-run"])
        .write_stdin("\u{feff}Line one\r\n日本語\rEnd")
        .assert()
        .success();
    let value: Value = serde_json::from_slice(&result.get_output().stdout).unwrap();
    assert_eq!(value["text"], "Line one\n日本語\nEnd");
    for args in [
        vec!["pages", "append", "page", "--dry-run"],
        vec!["pages", "append", "page", "--text", "", "--dry-run"],
        vec![
            "pages",
            "append",
            "page",
            "--text",
            "hi",
            "--file",
            "-",
            "--dry-run",
        ],
        vec![
            "pages",
            "create",
            "--section",
            "section",
            "--title",
            "\n",
            "--text",
            "hi",
            "--dry-run",
        ],
    ] {
        let result = Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
            .args(args)
            .assert()
            .code(2);
        assert!(result.get_output().stdout.is_empty());
    }
    for bytes in [vec![0xff], vec![b'x'; onenote_cli::write::MAX_TEXT + 1]] {
        Command::new(assert_cmd::cargo::cargo_bin!("onenote"))
            .args(["pages", "append", "page", "--file", "-", "--dry-run"])
            .write_stdin(bytes)
            .assert()
            .code(2);
    }
    for text in ["\u{ffff}", "\0", "\x1b"] {
        assert!(onenote_cli::write::validate(text, false).is_err());
    }
    let contract = schema::generate(None);
    for name in ["pages create", "pages append"] {
        let command = contract["commands"]
            .as_array()
            .unwrap()
            .iter()
            .find(|c| c["name"] == name)
            .unwrap();
        assert_eq!(command["effects"], "non_idempotent");
    }
}

#[test]
fn locked_section_is_not_reported_as_an_empty_page_list() {
    let xml = r#"<one:Section xmlns:one="http://schemas.microsoft.com/office/onenote/2013/onenote" ID="locked" name="Protected" encrypted="true" locked="true"/>"#;
    for result in [
        model::collection(xml, "Page", &args(25, 0)),
        model::search(xml, &args(25, 0)),
    ] {
        let error = result.unwrap_err();
        assert_eq!(error.kind, "desktop_error");
        assert!(error.message.contains("Unlock it in OneNote desktop"));
    }
    assert_eq!(
        model::collection(xml, "Section", &args(25, 0)).unwrap()["items"][0]["locked"],
        true
    );
    let unlocked = xml.replace(" locked=\"true\"", "");
    assert_eq!(
        model::collection(&unlocked, "Page", &args(25, 0)).unwrap()["total"],
        0
    );
}

#[test]
fn scan_matches_literal_visible_text_and_reports_partial_results() {
    use serde_json::json;
    let hierarchy = r#"<one:Section xmlns:one="http://schemas.microsoft.com/office/onenote/2013/onenote" ID="s" name="Section"><one:Page ID="p" name="Title"/><one:Page ID="bad"/></one:Section>"#;
    let xml = r#"<one:Page xmlns:one="http://schemas.microsoft.com/office/onenote/2013/onenote" ID="p" name="Title"><one:OE><one:T><![CDATA[Hello <b>TEAM</b> &amp; 日本語 <a href="https://hidden.example">visible</a><div>next</div>end &lt;script&gt; $(exit) <script>secret</script>]]></one:T></one:OE></one:Page>"#;
    let mut batch = json!({"xml":hierarchy,"pages":[{"id":"p","xml":xml}],"skipped_pages":[],"skipped_locked_sections":0,"scan_offset":0,"next_scan_offset":null,"candidate_pages":1,"attempted_pages":1});
    for query in [
        "hello team & 日本語",
        "TITLE",
        "visible next end",
        "<script> $(exit)",
    ] {
        let result = model::scan(&batch, query, &args(25, 0)).unwrap();
        assert_eq!(result["total"], 1, "{query}");
        assert_eq!(result["incomplete"], false);
        assert_eq!(result["items"][0]["path"], "Section / Title");
    }
    for query in [
        "hidden.example",
        "secret",
        "**TEAM**",
        "team AND 日本語",
        "missing",
    ] {
        assert_eq!(
            model::scan(&batch, query, &args(25, 0)).unwrap()["total"],
            0,
            "{query}"
        );
    }
    batch["pages"]
        .as_array_mut()
        .unwrap()
        .push(json!({"id":"bad","xml":"invalid"}));
    batch["skipped_locked_sections"] = json!(1);
    batch["next_scan_offset"] = json!(2);
    let result = model::scan(
        &batch,
        "hello",
        &PageArgs {
            fields: vec!["id".into()],
            ..args(1, 0)
        },
    )
    .unwrap();
    assert_eq!(result["scanned_pages"], 1);
    assert_eq!(result["skipped_pages"][0]["reason"], "invalid_page_xml");
    assert_eq!(result["incomplete"], true);
    assert_eq!(result["items"][0], json!({"id":"p"}));
    assert!(result["next_offset"].is_null());
    assert_eq!(result["next_scan_offset"], 2);
    assert!(output::render(&result, false).contains("Results are incomplete"));
    assert!(output::render(&result, false).contains("--scan-offset 2 and --offset 0"));
    assert!(
        model::scan(&batch, "hello", &args(1, 1)).unwrap()["items"]
            .as_array()
            .unwrap()
            .is_empty()
    );
}

#[test]
fn scan_bounds_require_explicit_scan_mode() {
    for extra in [
        vec!["--scan-limit", "10"],
        vec!["--scan-offset", "1"],
        vec!["--scan", "--scan-limit", "0"],
        vec!["--scan", "--scan-limit", "501"],
    ] {
        let mut args = vec!["onenote", "search", "test"];
        args.extend(extra);
        assert!(Cli::try_parse_from(args).is_err());
    }
    assert!(Cli::try_parse_from(["onenote", "search", "test", "--scan"]).is_ok());
}