mod common;
use common::{git, init_repo, run_plan_tooling, write_file};
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn validate_ok_with_explicit_file() {
let repo = init_repo();
write_file(&repo.path().join("plan.md"), VALID_PLAN);
let out = run_plan_tooling(repo.path(), &["validate", "--file", "plan.md"]);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
}
#[test]
fn validate_explicit_file_without_git_repo() {
let dir = TempDir::new().expect("tempdir");
write_file(&dir.path().join("plan.md"), VALID_PLAN);
let out = run_plan_tooling(dir.path(), &["validate", "--file", "plan.md"]);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
}
#[test]
fn validate_fails_with_errors() {
let repo = init_repo();
write_file(&repo.path().join("bad.md"), INVALID_PLAN);
let out = run_plan_tooling(repo.path(), &["validate", "--file", "bad.md"]);
assert_eq!(out.code, 1);
assert!(out.stdout.is_empty());
assert!(out.stderr.contains("error:"));
assert!(out.stderr.contains("Location"));
}
#[test]
fn validate_default_discovers_tracked_docs_plans() {
let repo = init_repo();
let plan_path = repo.path().join("docs/plans/example-plan.md");
write_file(&plan_path, VALID_PLAN);
git(repo.path(), &["add", "docs/plans/example-plan.md"]);
git(repo.path(), &["commit", "-m", "add plan", "-q"]);
let out = run_plan_tooling(repo.path(), &["validate"]);
assert_eq!(out.code, 0, "stderr: {}", out.stderr);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
}
#[test]
fn validate_repo_relative_file_works_from_nested_dir() {
let repo = init_repo();
let plan_path = repo.path().join("docs/plans/example-plan.md");
write_file(&plan_path, VALID_PLAN);
let nested = repo.path().join("nested/dir");
std::fs::create_dir_all(&nested).expect("create_dir_all");
let out = run_plan_tooling(
&nested,
&["validate", "--file", "docs/plans/example-plan.md"],
);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
}
#[test]
fn validate_missing_dependencies_is_error() {
let repo = init_repo();
write_file(&repo.path().join("missing-deps.md"), MISSING_DEPS_PLAN);
let out = run_plan_tooling(repo.path(), &["validate", "--file", "missing-deps.md"]);
assert_eq!(out.code, 1);
assert!(out.stdout.is_empty());
assert!(out.stderr.contains("missing Dependencies"));
}
#[test]
fn validate_redirect_command_is_not_placeholder() {
let repo = init_repo();
write_file(&repo.path().join("redirect.md"), REDIRECT_VALIDATION_PLAN);
let out = run_plan_tooling(repo.path(), &["validate", "--file", "redirect.md"]);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stdout.is_empty());
assert!(out.stderr.is_empty());
}
#[test]
fn validate_json_ok_with_explicit_file() {
let repo = init_repo();
write_file(&repo.path().join("plan.md"), VALID_PLAN);
let out = run_plan_tooling(
repo.path(),
&["validate", "--file", "plan.md", "--format", "json"],
);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stderr.is_empty());
let v: serde_json::Value = serde_json::from_str(&out.stdout).expect("json");
assert_eq!(v["ok"], true);
assert_eq!(v["files"], serde_json::json!(["plan.md"]));
assert_eq!(v["errors"], serde_json::json!([]));
}
#[test]
fn validate_json_returns_errors_and_exit_one() {
let repo = init_repo();
write_file(&repo.path().join("bad.md"), INVALID_PLAN);
let out = run_plan_tooling(
repo.path(),
&["validate", "--file", "bad.md", "--format", "json"],
);
assert_eq!(
out.code, 1,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stderr.is_empty());
let v: serde_json::Value = serde_json::from_str(&out.stdout).expect("json");
assert_eq!(v["ok"], false);
assert_eq!(v["files"], serde_json::json!(["bad.md"]));
let errs = v["errors"].as_array().expect("errors array");
assert!(!errs.is_empty());
assert!(errs.iter().any(|e| {
e.as_str()
.is_some_and(|s| s.contains("Location must be repo-relative"))
}));
}
#[test]
fn validate_json_no_files_emits_empty_payload() {
let dir = TempDir::new().expect("tempdir");
let out = run_plan_tooling(dir.path(), &["validate", "--format", "json"]);
assert_eq!(
out.code, 0,
"stdout: {}\nstderr: {}",
out.stdout, out.stderr
);
assert!(out.stderr.is_empty());
let v: serde_json::Value = serde_json::from_str(&out.stdout).expect("json");
assert_eq!(
v,
serde_json::json!({
"ok": true,
"files": [],
"errors": []
})
);
}
#[test]
fn validate_invalid_format_is_usage_error() {
let repo = init_repo();
write_file(&repo.path().join("plan.md"), VALID_PLAN);
let out = run_plan_tooling(
repo.path(),
&["validate", "--file", "plan.md", "--format", "yaml"],
);
assert_eq!(out.code, 2);
assert!(out.stdout.is_empty());
assert!(out.stderr.contains("invalid --format"));
}
const VALID_PLAN: &str = r#"# Plan: Example
## Sprint 1: First sprint
### Task 1.1: Do thing
- **Location**:
- `src/a.rs`
- **Description**: Do A
- **Dependencies**:
- none
- **Acceptance criteria**:
- A works
- **Validation**:
- cargo test -p plan-tooling
"#;
const INVALID_PLAN: &str = r#"# Plan: Bad
## Sprint 1: Bad sprint
### Task 1.1: Broken
- **Location**:
- `/abs/path.rs`
- **Description**: TODO
- **Dependencies**:
- Task 1.2
- **Acceptance criteria**:
- <TBD>
- **Validation**:
- TBD
"#;
const MISSING_DEPS_PLAN: &str = r#"# Plan: Missing deps
## Sprint 1: First sprint
### Task 1.1: Do thing
- **Location**:
- `src/a.rs`
- **Description**: Do A
- **Dependencies**:
- **Acceptance criteria**:
- A works
- **Validation**:
- cargo test -p plan-tooling
"#;
const REDIRECT_VALIDATION_PLAN: &str = r#"# Plan: Redirect
## Sprint 1: First sprint
### Task 1.1: Validate shell redirect command
- **Location**:
- `src/a.rs`
- **Description**: Keep redirect-based checks
- **Dependencies**:
- none
- **Acceptance criteria**:
- Redirect command is accepted
- **Validation**:
- cat < input.txt > output.txt
"#;