dotrepo-cli 1.0.3

Reference CLI for the dotrepo repository metadata protocol.
Documentation
use serde_json::Value;
use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn dotrepo_bin() -> &'static str {
    env!("CARGO_BIN_EXE_dotrepo")
}

fn run_dotrepo(args: &[&str]) -> std::process::Output {
    Command::new(dotrepo_bin())
        .args(args)
        .output()
        .expect("dotrepo command runs")
}

fn parse_stdout_json(output: &std::process::Output) -> Value {
    serde_json::from_slice(&output.stdout).expect("stdout contains json")
}

fn temp_dir(label: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time is after unix epoch")
        .as_nanos();
    let root = std::env::temp_dir().join(format!("dotrepo-cli-{label}-{nanos}"));
    fs::create_dir_all(&root).expect("temp dir created");
    root
}

fn object_keys(value: &Value) -> BTreeSet<String> {
    value
        .as_object()
        .expect("json value should be an object")
        .keys()
        .cloned()
        .collect()
}

#[test]
fn preview_json_reports_lossy_contributing_replacement() {
    let root = temp_dir("preview-contributing");
    fs::write(
        root.join(".repo"),
        r#"
schema = "dotrepo/v0.1"

[record]
mode = "native"
status = "reviewed"

[repo]
name = "example"
description = "Example project"
build = "cargo build --locked"
test = "cargo nextest run --locked"

[owners]
maintainers = ["@alice"]
security_contact = "security@example.com"

[compat.github]
contributing = "generate"
"#,
    )
    .expect(".repo written");
    fs::write(
        root.join("CONTRIBUTING.md"),
        r#"# Contributing

Use the repository-specific release checklist before you open a pull request.
"#,
    )
    .expect("CONTRIBUTING written");

    let output = run_dotrepo(&[
        "--root",
        root.to_str().expect("temp path is utf-8"),
        "preview",
        "--surface",
        "contributing",
        "--json",
    ]);

    assert!(output.status.success(), "preview --json should succeed");
    assert!(output.stderr.is_empty(), "success should not write stderr");

    let json = parse_stdout_json(&output);
    let expected_top_level = BTreeSet::from(["previews".to_string(), "root".to_string()]);
    assert_eq!(object_keys(&json), expected_top_level);

    let previews = json["previews"]
        .as_array()
        .expect("previews should be an array");
    assert_eq!(previews.len(), 1);
    let preview = &previews[0];

    let expected_keys = BTreeSet::from([
        "advice".to_string(),
        "current".to_string(),
        "declaredMode".to_string(),
        "fullReplacement".to_string(),
        "message".to_string(),
        "ownershipHonesty".to_string(),
        "path".to_string(),
        "preservesUnmanagedContent".to_string(),
        "proposed".to_string(),
        "recommendedMode".to_string(),
        "rendererCoverage".to_string(),
        "state".to_string(),
        "supportsFullGeneration".to_string(),
        "supportsManagedRegions".to_string(),
        "surface".to_string(),
        "wouldDropUnmanagedContent".to_string(),
    ]);
    assert_eq!(object_keys(preview), expected_keys);

    assert_eq!(preview["surface"], Value::String("contributing".into()));
    assert_eq!(preview["declaredMode"], Value::String("generate".into()));
    assert_eq!(preview["supportsManagedRegions"], Value::Bool(true));
    assert_eq!(preview["supportsFullGeneration"], Value::Bool(true));
    assert_eq!(
        preview["ownershipHonesty"],
        Value::String("lossy_full_generation".into())
    );
    assert_eq!(
        preview["recommendedMode"],
        Value::String("partially_managed".into())
    );
    assert_eq!(preview["wouldDropUnmanagedContent"], Value::Bool(true));
    assert_eq!(preview["fullReplacement"], Value::Bool(true));
    assert_eq!(preview["preservesUnmanagedContent"], Value::Bool(false));
    assert!(preview["current"].is_string());
    assert!(preview["proposed"]
        .as_str()
        .expect("proposed is a string")
        .contains("## Before you open a change"));
    assert!(preview["advice"]
        .as_array()
        .expect("advice should be an array")
        .iter()
        .any(|item| item
            .as_str()
            .expect("advice items should be strings")
            .contains("managed regions")
            || item
                .as_str()
                .expect("advice items should be strings")
                .contains("dotrepo preview")));

    fs::remove_dir_all(root).expect("temp dir removed");
}

#[test]
fn preview_json_preserves_outer_readme_prose_for_partially_managed_files() {
    let root = temp_dir("preview-readme");
    fs::write(
        root.join(".repo"),
        r#"
schema = "dotrepo/v0.1"

[record]
mode = "native"
status = "reviewed"

[repo]
name = "example"
description = "Example project"

[readme]
title = "Example"
tagline = "Managed README"
"#,
    )
    .expect(".repo written");
    fs::write(
        root.join("README.md"),
        r#"Project-specific introduction.

<!-- dotrepo:begin id=readme.body -->
old managed content
<!-- dotrepo:end id=readme.body -->

Repository-specific footer.
"#,
    )
    .expect("README written");

    let output = run_dotrepo(&[
        "--root",
        root.to_str().expect("temp path is utf-8"),
        "preview",
        "--surface",
        "readme",
        "--json",
    ]);

    assert!(output.status.success(), "preview --json should succeed");
    assert!(output.stderr.is_empty(), "success should not write stderr");

    let json = parse_stdout_json(&output);
    let preview = &json["previews"]
        .as_array()
        .expect("previews should be an array")[0];

    assert_eq!(preview["surface"], Value::String("readme".into()));
    assert_eq!(preview["ownershipHonesty"], Value::String("honest".into()));
    assert_eq!(
        preview["recommendedMode"],
        Value::String("partially_managed".into())
    );
    assert_eq!(preview["wouldDropUnmanagedContent"], Value::Bool(false));
    assert_eq!(preview["fullReplacement"], Value::Bool(false));
    assert_eq!(preview["preservesUnmanagedContent"], Value::Bool(true));

    let proposed = preview["proposed"].as_str().expect("proposed is a string");
    assert!(proposed.contains("Project-specific introduction."));
    assert!(proposed.contains("# Example"));
    assert!(proposed.contains("> Managed README"));
    assert!(proposed.contains("Repository-specific footer."));

    fs::remove_dir_all(root).expect("temp dir removed");
}

#[test]
fn preview_json_preserves_outer_security_prose_for_partially_managed_files() {
    let root = temp_dir("preview-security");
    fs::create_dir_all(root.join(".github")).expect(".github created");
    fs::write(
        root.join(".repo"),
        r#"
schema = "dotrepo/v0.1"

[record]
mode = "native"
status = "reviewed"

[repo]
name = "example"
description = "Example project"

[owners]
maintainers = ["@alice"]
security_contact = "security@example.com"

[compat.github]
security = "generate"
"#,
    )
    .expect(".repo written");
    fs::write(
        root.join(".github/SECURITY.md"),
        r#"Project-specific introduction.

<!-- dotrepo:begin id=security.body -->
old managed content
<!-- dotrepo:end id=security.body -->

Repository-specific disclosure notes.
"#,
    )
    .expect("SECURITY written");

    let output = run_dotrepo(&[
        "--root",
        root.to_str().expect("temp path is utf-8"),
        "preview",
        "--surface",
        "security",
        "--json",
    ]);

    assert!(output.status.success(), "preview --json should succeed");
    assert!(output.stderr.is_empty(), "success should not write stderr");

    let json = parse_stdout_json(&output);
    let preview = &json["previews"]
        .as_array()
        .expect("previews should be an array")[0];

    assert_eq!(preview["surface"], Value::String("security".into()));
    assert_eq!(preview["ownershipHonesty"], Value::String("honest".into()));
    assert_eq!(
        preview["recommendedMode"],
        Value::String("partially_managed".into())
    );
    assert_eq!(preview["wouldDropUnmanagedContent"], Value::Bool(false));
    assert_eq!(preview["fullReplacement"], Value::Bool(false));
    assert_eq!(preview["preservesUnmanagedContent"], Value::Bool(true));

    let proposed = preview["proposed"].as_str().expect("proposed is a string");
    assert!(proposed.contains("Project-specific introduction."));
    assert!(proposed.contains("Please report vulnerabilities to security@example.com."));
    assert!(proposed.contains("Repository-specific disclosure notes."));

    fs::remove_dir_all(root).expect("temp dir removed");
}

#[test]
fn preview_human_output_explains_all_or_nothing_pull_request_template_behavior() {
    let root = temp_dir("preview-pr-template");
    fs::create_dir_all(root.join(".github")).expect(".github created");
    fs::write(
        root.join(".repo"),
        r#"
schema = "dotrepo/v0.1"

[record]
mode = "native"
status = "reviewed"

[repo]
name = "example"
description = "Example project"
test = "cargo nextest run --locked"

[compat.github]
pull_request_template = "generate"
"#,
    )
    .expect(".repo written");
    fs::write(
        root.join(".github/pull_request_template.md"),
        r#"## Type of change

- [ ] Feature
- [ ] Fix

## Repo-specific checks

- [ ] Mention the rollout plan.
"#,
    )
    .expect("PR template written");

    let output = run_dotrepo(&[
        "--root",
        root.to_str().expect("temp path is utf-8"),
        "preview",
        "--surface",
        "pull-request-template",
    ]);

    assert!(output.status.success(), "preview should succeed");
    assert!(output.stderr.is_empty(), "success should not write stderr");

    let stdout = String::from_utf8(output.stdout).expect("stdout is utf-8");
    assert!(stdout.contains("surface: pull_request_template"));
    assert!(stdout.contains("recommended mode: skip"));
    assert!(stdout.contains("replacement mode: full_replacement"));
    assert!(stdout.contains("partial management is not supported"));

    fs::remove_dir_all(root).expect("temp dir removed");
}

#[test]
fn preview_refuses_overlay_records() {
    let root = temp_dir("preview-overlay");
    fs::write(
        root.join("record.toml"),
        r#"
schema = "dotrepo/v0.1"

[record]
mode = "overlay"
status = "reviewed"
source = "https://github.com/example/project"

[record.trust]
confidence = "medium"
provenance = ["verified"]

[repo]
name = "project"
description = "Example project"
"#,
    )
    .expect("record written");

    let output = run_dotrepo(&[
        "--root",
        root.to_str().expect("temp path is utf-8"),
        "preview",
        "--surface",
        "security",
    ]);

    assert_eq!(output.status.code(), Some(2));
    let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
    assert!(stderr.contains("preview is only supported for native records"));

    fs::remove_dir_all(root).expect("temp dir removed");
}