Skip to main content

rac_engine/
commands.rs

1//! Command orchestration: walk -> parse -> classify -> validate -> render.
2//! Output is order-deterministic.
3
4use std::path::{Path, PathBuf};
5
6use crate::output;
7use crate::parse::{parse_file, parse_text, Artifact, Issue};
8use crate::relationships::{
9    build_relationship_report, build_relationship_report_file, corpus_items,
10    validate_document_against_corpus, validate_relationships, validate_relationships_file,
11    RelationshipIssue,
12};
13use crate::validate::{
14    apply_overrides, check_okf_conformance, has_errors, load_overrides, load_ticketing_provider,
15    validate, validate_product, OkfConformanceReport, OkfEntry,
16};
17use crate::walk::normalize_root;
18
19pub const EXIT_OK: i32 = 0;
20pub const EXIT_VALIDATION_FAILED: i32 = 1;
21pub const EXIT_USAGE: i32 = 2;
22
23// Stable per-file statuses (JSON contract).
24pub const STATUS_VALID: &str = "valid";
25pub const STATUS_INVALID: &str = "invalid";
26pub const STATUS_SKIPPED: &str = "skipped";
27
28fn usage_error(message: &str) -> i32 {
29    eprintln!("decided: {message}");
30    EXIT_USAGE
31}
32
33fn emit(text: String) {
34    use std::io::Write;
35    // stdin surrogateescape sentinels re-materialize as their raw bytes on
36    // stdout (the oracle's stdout encoder uses surrogateescape). No-op —
37    // a borrowed passthrough — unless stdin decoding produced sentinels.
38    let payload = crate::pycompat::encode_stdout_surrogateescape(&text);
39    let mut stdout = std::io::stdout().lock();
40    let _ = stdout.write_all(&payload);
41    let _ = stdout.write_all(b"\n");
42    let _ = stdout.flush();
43}
44
45// ---------------------------------------------------------------------------
46// Service results (decided.services.validate)
47// ---------------------------------------------------------------------------
48
49pub struct FileValidation {
50    pub path: String,
51    pub artifact_type: String,
52    pub status: &'static str,
53    pub issues: Vec<Issue>,
54}
55
56pub struct DirectoryValidation {
57    pub directory: String,
58    pub recursive: bool,
59    pub files: Vec<FileValidation>,
60    pub okf: Option<OkfConformanceReport>,
61}
62
63impl DirectoryValidation {
64    pub fn checked(&self) -> usize {
65        self.files.iter().filter(|f| f.status != STATUS_SKIPPED).count()
66    }
67
68    pub fn valid(&self) -> usize {
69        self.files.iter().filter(|f| f.status == STATUS_VALID).count()
70    }
71
72    pub fn invalid(&self) -> usize {
73        self.files.iter().filter(|f| f.status == STATUS_INVALID).count()
74    }
75
76    pub fn skipped(&self) -> usize {
77        self.files.iter().filter(|f| f.status == STATUS_SKIPPED).count()
78    }
79
80    pub fn ok(&self) -> bool {
81        self.invalid() == 0 && self.okf.as_ref().map(|o| o.ok()).unwrap_or(true)
82    }
83}
84
85pub struct StdinCorpusValidation {
86    pub source_path: String,
87    pub structural_issues: Vec<Issue>,
88    pub relationship_issues: Vec<RelationshipIssue>,
89}
90
91impl StdinCorpusValidation {
92    pub fn ok(&self) -> bool {
93        !has_errors(&self.structural_issues) && self.relationship_issues.is_empty()
94    }
95}
96
97/// `validate_directory(directory, recursive)` — the uncached walk (the cache
98/// path is contractually byte-identical, PORT-CONTRACT.d/01 §6).
99pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidation {
100    let entries = corpus_items(directory, recursive);
101    let overrides = load_overrides(directory);
102    let provider = load_ticketing_provider(directory);
103    // Per-file validation in parallel over the sorted corpus (PORT-CONTRACT
104    // decision 5): an indexed rayon iterator, so `collect` preserves the
105    // sorted order and the worker count is invisible in the output. The
106    // shared inputs (overrides, provider) are read-only.
107    use rayon::prelude::*;
108    let files: Vec<FileValidation> = entries
109        .par_iter()
110        .map(|item| {
111            let artifact_type = item
112                .spec
113                .map(|s| s.name.clone())
114                .unwrap_or_else(|| "unknown".to_string());
115            if item.spec.is_none() {
116                return FileValidation {
117                    path: item.path.clone(),
118                    artifact_type,
119                    status: STATUS_SKIPPED,
120                    issues: Vec::new(),
121                };
122            }
123            let issues = apply_overrides(
124                validate(&item.artifact, provider.as_deref(), Some(&artifact_type)),
125                &artifact_type,
126                &overrides,
127            );
128            let status = if has_errors(&issues) {
129                STATUS_INVALID
130            } else {
131                STATUS_VALID
132            };
133            FileValidation {
134                path: item.path.clone(),
135                artifact_type,
136                status,
137                issues,
138            }
139        })
140        .collect();
141    let okf_entries: Vec<OkfEntry> = entries
142        .iter()
143        .map(|item| OkfEntry {
144            path: &item.path,
145            artifact_type: item
146                .spec
147                .map(|s| s.name.as_str())
148                .unwrap_or("unknown"),
149            file_name: item.path.rsplit('/').next().unwrap_or(&item.path),
150        })
151        .collect();
152    let okf = check_okf_conformance(&okf_entries, &overrides);
153    DirectoryValidation {
154        directory: directory.to_string(),
155        recursive,
156        files,
157        okf: Some(okf),
158    }
159}
160
161/// A fingerprint of the ancestor-walked `.decided/config.yaml` governing
162/// `directory` — the per-file cache key's config half (ADR-106).
163fn config_fingerprint(directory: &str) -> String {
164    let mut hasher = crate::sha256::Sha256::new();
165    match crate::validate::find_config_file(directory) {
166        None => hasher.update(b"\x00no-config"),
167        Some(config_path) => {
168            hasher.update(config_path.display().to_string().as_bytes());
169            hasher.update(b"\0");
170            match std::fs::read(&config_path) {
171                Ok(bytes) => hasher.update(&bytes),
172                Err(_) => hasher.update(b"\x00unreadable-config"),
173            }
174        }
175    }
176    hasher.hexdigest()
177}
178
179/// A stable per-corpus-root store key: SHA-256 of the resolved path.
180fn validate_root_key(directory: &str) -> String {
181    let resolved = crate::index_store::py_resolve(directory);
182    crate::sha256::hexdigest(resolved.display().to_string().as_bytes())
183}
184
185/// `validate_directory_incremental(directory, recursive, verify)` — the
186/// ADR-106 changeset-bound path, byte-identical to `validate_directory` for
187/// the same corpus and config. Unchanged files reuse their cached path-free
188/// result verbatim; changed files re-parse and re-validate; assembly runs in
189/// walk order; OKF conformance recomputes over `(artifact_type, path)` shims.
190pub fn validate_directory_incremental(
191    directory: &str,
192    recursive: bool,
193    verify: bool,
194) -> DirectoryValidation {
195    validate_directory_incremental_in(directory, recursive, verify, None)
196}
197
198/// The cache-dir-injectable body (`cache_dir=None` resolves the ladder) —
199/// the seam the S5 pinning test drives without touching process env.
200pub fn validate_directory_incremental_in(
201    directory: &str,
202    recursive: bool,
203    verify: bool,
204    cache_dir: Option<&Path>,
205) -> DirectoryValidation {
206    use crate::index_store::{
207        open_validation_store, write_validation_store, FileState, ValidationCacheRow,
208    };
209    let timing = std::env::var_os("DECIDED_TIMING").is_some();
210    let cache_dir = cache_dir
211        .map(Path::to_path_buf)
212        .unwrap_or_else(crate::derived_cache::default_cache_dir);
213    let root_key = validate_root_key(directory);
214    let config_hash = config_fingerprint(directory);
215
216    let prev_rows =
217        open_validation_store(&cache_dir, &root_key, &config_hash).unwrap_or_default();
218    let prev_manifest: Vec<(String, FileState)> = prev_rows
219        .iter()
220        .map(|(rel, row)| {
221            (
222                rel.clone(),
223                FileState {
224                    content_hash: row.content_hash.clone(),
225                    size: row.size,
226                    mtime_ns: row.mtime_ns,
227                },
228            )
229        })
230        .collect();
231    let prev_by_rel: std::collections::HashMap<&str, &ValidationCacheRow> = prev_rows
232        .iter()
233        .map(|(rel, row)| (rel.as_str(), row))
234        .collect();
235
236    let detect_start = std::time::Instant::now();
237    let (new_manifest, changed) =
238        crate::derived_cache::stat_scan(directory, &prev_manifest, verify, recursive);
239    let detect_ms = detect_start.elapsed().as_secs_f64() * 1000.0;
240
241    let overrides = load_overrides(directory);
242    let provider = load_ticketing_provider(directory);
243    let root_display = normalize_root(directory);
244
245    let recompute_start = std::time::Instant::now();
246    let mut new_rows: Vec<(String, ValidationCacheRow)> =
247        Vec::with_capacity(new_manifest.len());
248    for (rel, state) in &new_manifest {
249        if !changed.contains(rel) {
250            if let Some(prev) = prev_by_rel.get(rel.as_str()) {
251                // Unchanged content under an unchanged config: reuse the
252                // path-free result verbatim, refreshing only the stat proxy.
253                new_rows.push((
254                    rel.clone(),
255                    ValidationCacheRow {
256                        size: state.size,
257                        mtime_ns: state.mtime_ns,
258                        content_hash: state.content_hash.clone(),
259                        artifact_type: prev.artifact_type.clone(),
260                        status: prev.status.clone(),
261                        issues: prev.issues.clone(),
262                    },
263                ));
264                continue;
265            }
266        }
267        let path = format!("{root_display}/{rel}");
268        let artifact = parse_file(&path);
269        let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
270        let artifact_type = spec
271            .map(|s| s.name.clone())
272            .unwrap_or_else(|| "unknown".to_string());
273        let (status, issues) = if spec.is_none() {
274            (STATUS_SKIPPED.to_string(), Vec::new())
275        } else {
276            let computed = apply_overrides(
277                validate(&artifact, provider.as_deref(), Some(&artifact_type)),
278                &artifact_type,
279                &overrides,
280            );
281            let status = if has_errors(&computed) {
282                STATUS_INVALID
283            } else {
284                STATUS_VALID
285            };
286            (
287                status.to_string(),
288                computed
289                    .into_iter()
290                    .map(|issue| crate::index_store::CachedIssue {
291                        severity: issue.severity.to_string(),
292                        code: issue.code.clone(),
293                        message: issue.message.clone(),
294                        line: issue.line.map(|l| l as u32),
295                    })
296                    .collect(),
297            )
298        };
299        new_rows.push((
300            rel.clone(),
301            ValidationCacheRow {
302                size: state.size,
303                mtime_ns: state.mtime_ns,
304                content_hash: state.content_hash.clone(),
305                artifact_type,
306                status,
307                issues,
308            },
309        ));
310    }
311    let recompute_ms = recompute_start.elapsed().as_secs_f64() * 1000.0;
312
313    // Assemble in walk order — byte-identical file and issue order.
314    let rows_by_rel: std::collections::HashMap<&str, &ValidationCacheRow> = new_rows
315        .iter()
316        .map(|(rel, row)| (rel.as_str(), row))
317        .collect();
318    let mut files: Vec<FileValidation> = Vec::new();
319    let mut okf_entries_owned: Vec<(String, String, String)> = Vec::new();
320    for entry in crate::walk::find_markdown_files(directory, recursive) {
321        let rel = entry.components.join("/");
322        let Some(row) = rows_by_rel.get(rel.as_str()) else {
323            continue; // created between scan and assembly — next run settles it
324        };
325        let status: &'static str = match row.status.as_str() {
326            "valid" => STATUS_VALID,
327            "invalid" => STATUS_INVALID,
328            _ => STATUS_SKIPPED,
329        };
330        files.push(FileValidation {
331            path: entry.display.clone(),
332            artifact_type: row.artifact_type.clone(),
333            status,
334            issues: row
335                .issues
336                .iter()
337                .map(|i| Issue {
338                    severity: match i.severity.as_str() {
339                        "error" => "error",
340                        "warning" => "warning",
341                        _ => "info",
342                    },
343                    code: i.code.clone(),
344                    message: i.message.clone(),
345                    line: i.line.map(i64::from),
346                })
347                .collect(),
348        });
349        let file_name = entry
350            .display
351            .rsplit('/')
352            .next()
353            .unwrap_or(&entry.display)
354            .to_string();
355        okf_entries_owned.push((entry.display.clone(), row.artifact_type.clone(), file_name));
356    }
357    let okf_entries: Vec<OkfEntry> = okf_entries_owned
358        .iter()
359        .map(|(path, artifact_type, file_name)| OkfEntry {
360            path,
361            artifact_type,
362            file_name,
363        })
364        .collect();
365    let okf = check_okf_conformance(&okf_entries, &overrides);
366
367    write_validation_store(&cache_dir, &root_key, &config_hash, &new_rows);
368
369    if timing {
370        eprintln!(
371            "decided-timing: detect_ms={detect_ms:.3} recompute_ms={recompute_ms:.3} files_changed={}",
372            changed.len()
373        );
374    }
375
376    DirectoryValidation {
377        directory: directory.to_string(),
378        recursive,
379        files,
380        okf: Some(okf),
381    }
382}
383
384/// `validate_stdin_against_corpus(product, corpus_dir, source_path)`.
385pub fn validate_stdin_against_corpus(
386    artifact: &Artifact,
387    corpus_dir: &str,
388    source_path: &str,
389    recursive: bool,
390) -> StdinCorpusValidation {
391    let structural = validate_product(artifact, corpus_dir);
392    let relationships =
393        validate_document_against_corpus(artifact, source_path, corpus_dir, recursive);
394    StdinCorpusValidation {
395        source_path: source_path.to_string(),
396        structural_issues: structural,
397        relationship_issues: relationships.issues,
398    }
399}
400
401// ---------------------------------------------------------------------------
402// cmd_validate
403// ---------------------------------------------------------------------------
404
405pub struct ValidateArgs {
406    pub file: String,
407    pub json: bool,
408    pub sarif: bool,
409    pub top_level: bool,
410    pub corpus: Option<String>,
411    /// `--cache` / `--no-cache` (ADR-112: on by default).
412    pub cache: bool,
413    /// `--verify`: full content re-hash of the cache freshness check.
414    pub verify: bool,
415}
416
417/// `str(Path(p))` — PurePosixPath normalization of a CLI path argument.
418fn py_path_str(p: &str) -> String {
419    normalize_root(p)
420}
421
422/// `str(Path(p).parent)`.
423fn py_path_parent(p: &str) -> String {
424    let normalized = py_path_str(p);
425    if normalized == "/" || normalized == "." {
426        return normalized;
427    }
428    match normalized.rfind('/') {
429        Some(0) => "/".to_string(),
430        Some(i) => normalized[..i].to_string(),
431        None => ".".to_string(),
432    }
433}
434
435/// `_read(path)` — a directly named file that is missing or unreadable is a
436/// usage error. Returns Err(exit_code) on usage failure.
437fn read_named_file(path: &str) -> Result<Artifact, i32> {
438    if !Path::new(path).is_file() {
439        return Err(usage_error(&format!("file not found: {path}")));
440    }
441    let artifact = parse_file(path);
442    if artifact
443        .parse_issues
444        .iter()
445        .any(|i| i.code == "unreadable-artifact")
446    {
447        return Err(usage_error(&format!("cannot read {path}")));
448    }
449    Ok(artifact)
450}
451
452fn read_validate_input(target: &str) -> Result<Artifact, i32> {
453    if target == "-" {
454        use std::io::Read;
455        let mut buf = Vec::new();
456        let _ = std::io::stdin().lock().read_to_end(&mut buf);
457        // The oracle reads stdin as TEXT with errors="surrogateescape" —
458        // NOT the errors="replace" lossy decode used for files.
459        let text = crate::pycompat::decode_stdin_surrogateescape(&buf);
460        return Ok(parse_text(&text, "-"));
461    }
462    read_named_file(target)
463}
464
465pub fn cmd_validate(args: &ValidateArgs) -> i32 {
466    // Directory? Validate every recognized artifact beneath it.
467    if args.file != "-" && Path::new(&args.file).is_dir() {
468        if args.corpus.is_some() {
469            return usage_error("--corpus applies to stdin ('-') or a single file");
470        }
471        // The cache reuses per-file results across runs (ADR-106),
472        // byte-identical to the uncached path; on by default per ADR-112.
473        let result = if crate::derived_cache::cache_enabled(args.cache) {
474            validate_directory_incremental(&args.file, !args.top_level, args.verify)
475        } else {
476            validate_directory(&args.file, !args.top_level)
477        };
478        if args.sarif {
479            emit(output::render_validate_sarif(&result));
480        } else if args.json {
481            emit(output::render_validate_dir_json(&result));
482        } else {
483            emit(output::render_validate_dir_human(&result));
484        }
485        return if result.ok() {
486            EXIT_OK
487        } else {
488            EXIT_VALIDATION_FAILED
489        };
490    }
491
492    if args.sarif {
493        return usage_error("--sarif applies to directory validation");
494    }
495
496    let artifact = match read_validate_input(&args.file) {
497        Ok(a) => a,
498        Err(code) => return code,
499    };
500
501    if let Some(corpus) = &args.corpus {
502        if !Path::new(corpus).is_dir() {
503            return usage_error(&format!("--corpus is not a directory: {corpus}"));
504        }
505        let source_path = if args.file == "-" {
506            "-".to_string()
507        } else {
508            py_path_str(&args.file)
509        };
510        let result = validate_stdin_against_corpus(&artifact, corpus, &source_path, true);
511        if args.json {
512            emit(output::render_stdin_corpus_json(&result));
513        } else {
514            emit(output::render_stdin_corpus_human(&result));
515        }
516        return if result.ok() {
517            EXIT_OK
518        } else {
519            EXIT_VALIDATION_FAILED
520        };
521    }
522
523    let start = if args.file == "-" {
524        ".".to_string()
525    } else {
526        py_path_parent(&args.file)
527    };
528    let issues = validate_product(&artifact, &start);
529    if args.json {
530        emit(output::render_validation_json(
531            &artifact.product.source_path,
532            &issues,
533        ));
534    } else {
535        emit(output::render_validation_human(
536            &artifact.product.source_path,
537            &issues,
538        ));
539    }
540    if has_errors(&issues) {
541        EXIT_VALIDATION_FAILED
542    } else {
543        EXIT_OK
544    }
545}
546
547// ---------------------------------------------------------------------------
548// cmd_diff
549// ---------------------------------------------------------------------------
550
551pub struct DiffArgs {
552    pub old: String,
553    pub new: String,
554    pub json: bool,
555}
556
557pub fn cmd_diff(args: &DiffArgs) -> i32 {
558    // `old` is `_read()` before `new`, so a bad old path wins the error.
559    let old = match read_named_file(&args.old) {
560        Ok(a) => a,
561        Err(code) => return code,
562    };
563    let new = match read_named_file(&args.new) {
564        Ok(a) => a,
565        Err(code) => return code,
566    };
567    let result = crate::diff::diff(&old, &new);
568    if args.json {
569        emit(output::render_diff_json(&result, &args.old, &args.new));
570    } else {
571        emit(output::render_diff_human(&result));
572    }
573    EXIT_OK
574}
575
576// ---------------------------------------------------------------------------
577// cmd_inspect / cmd_improve
578// ---------------------------------------------------------------------------
579
580/// `Path(target).suffix.lower()` — the final `.`-suffix of the last path
581/// component, empty for dotless names, leading-dot names, and trailing dots.
582fn py_suffix_lower(target: &str) -> String {
583    let name = target.rsplit('/').next().unwrap_or(target);
584    match name.rfind('.') {
585        Some(i) if i > 0 && i < name.len() - 1 => name[i..].to_lowercase(),
586        _ => String::new(),
587    }
588}
589
590/// `_read_markdown_input(target, command)` — a Markdown file or stdin (`-`).
591fn read_markdown_input(target: &str, command: &str) -> Result<String, i32> {
592    if target == "-" {
593        use std::io::Read;
594        let mut buf = Vec::new();
595        let _ = std::io::stdin().lock().read_to_end(&mut buf);
596        // `sys.stdin.read()` under the harness locale decodes UTF-8 with
597        // errors="surrogateescape" — same seam as `validate -`.
598        return Ok(crate::pycompat::decode_stdin_surrogateescape(&buf));
599    }
600    if !Path::new(target).is_file() {
601        return Err(usage_error(&format!("file not found: {target}")));
602    }
603    let suffix = py_suffix_lower(target);
604    if suffix != ".md" && suffix != ".markdown" {
605        return Err(usage_error(&format!(
606            "{command} expects a Markdown file; convert {target} first with an AsDecided ingestion connector"
607        )));
608    }
609    match std::fs::read(target) {
610        Ok(bytes) => match String::from_utf8(bytes) {
611            Ok(text) => Ok(text),
612            // The oracle's `path.read_text(encoding="utf-8")` decodes
613            // strictly: invalid UTF-8 raises UnicodeDecodeError, which no
614            // handler catches — an unhandled traceback, exit 1, empty stdout.
615            Err(e) => {
616                eprintln!(
617                    "UnicodeDecodeError: 'utf-8' codec can't decode input: {e}"
618                );
619                Err(EXIT_VALIDATION_FAILED)
620            }
621        },
622        // OSError -> `decided: cannot read <t>: <err>`, exit 2.
623        Err(e) => Err(usage_error(&format!("cannot read {target}: {e}"))),
624    }
625}
626
627pub struct InspectArgs {
628    pub file: String,
629    pub verbose: bool,
630    pub top_level: bool,
631    pub json: bool,
632}
633
634pub fn cmd_inspect(args: &InspectArgs) -> i32 {
635    // Directory? Aggregate per-file results into type counts. (The directory
636    // check precedes the .md extension guard — and never applies to `-`.)
637    if args.file != "-" && Path::new(&args.file).is_dir() {
638        let result = crate::inspect::inspect_directory(&args.file, !args.top_level);
639        if args.json {
640            emit(output::render_dir_inspect_json(&result));
641        } else {
642            emit(output::render_dir_inspect_human(&result));
643        }
644        return EXIT_OK;
645    }
646
647    // Single file (or stdin).
648    let text = match read_markdown_input(&args.file, "inspect") {
649        Ok(t) => t,
650        Err(code) => return code,
651    };
652    let artifact = parse_text(&text, "");
653    let inspection = crate::inspect::build_inspection(&artifact);
654    if args.verbose && !args.json {
655        emit(output::render_inspect_verbose(
656            &inspection,
657            &crate::classify::score_artifacts(&artifact),
658        ));
659    } else if args.json {
660        emit(output::render_inspect_json(&inspection));
661    } else {
662        emit(output::render_inspect_human(&inspection));
663    }
664    // A completed inspection always succeeds — Unknown is a valid outcome.
665    EXIT_OK
666}
667
668pub struct ImproveArgs {
669    pub file: String,
670    pub json: bool,
671    pub template: bool,
672}
673
674pub fn cmd_improve(args: &ImproveArgs) -> i32 {
675    let text = match read_markdown_input(&args.file, "improve") {
676        Ok(t) => t,
677        Err(code) => return code,
678    };
679    let result = crate::improve::improve_product(&parse_text(&text, ""));
680    if args.json {
681        emit(output::render_improve_json(&result));
682    } else if args.template {
683        emit(output::render_improve_template(&result));
684    } else {
685        emit(output::render_improve_human(&result));
686    }
687    // Advisory: a completed analysis always succeeds.
688    EXIT_OK
689}
690
691// ---------------------------------------------------------------------------
692// cmd_relationships (--validate arm; inspection arm is out of this phase)
693// ---------------------------------------------------------------------------
694
695pub struct RelationshipsArgs {
696    pub path: String,
697    pub validate: bool,
698    pub sarif: bool,
699    pub json: bool,
700    pub top_level: bool,
701}
702
703pub fn cmd_relationships(args: &RelationshipsArgs) -> i32 {
704    if args.sarif && !args.validate {
705        return usage_error("relationships --sarif requires --validate");
706    }
707    let path = Path::new(&args.path);
708    let is_dir = if path.is_dir() {
709        true
710    } else if path.is_file() {
711        let suffix = args
712            .path
713            .rsplit('/')
714            .next()
715            .and_then(|name| name.rfind('.').map(|i| name[i..].to_lowercase()))
716            .unwrap_or_default();
717        if suffix != ".md" && suffix != ".markdown" {
718            return usage_error(&format!(
719                "relationships expects a Markdown file or directory: {}; \
720                 convert it first with an AsDecided ingestion connector",
721                args.path
722            ));
723        }
724        false
725    } else {
726        return usage_error(&format!("path not found: {}", args.path));
727    };
728
729    if args.validate {
730        let report = if is_dir {
731            validate_relationships(&args.path, !args.top_level)
732        } else {
733            validate_relationships_file(&args.path)
734        };
735        if args.sarif {
736            emit(output::render_relationships_sarif(&report));
737        } else if args.json {
738            emit(output::render_relationship_validation_json(&report));
739        } else {
740            emit(output::render_relationship_validation_human(&report));
741        }
742        return if report.ok() {
743            EXIT_OK
744        } else {
745            EXIT_VALIDATION_FAILED
746        };
747    }
748
749    // Inspection arm (non --validate): always exit 0.
750    let report = if is_dir {
751        build_relationship_report(&args.path, !args.top_level)
752    } else {
753        build_relationship_report_file(&args.path)
754    };
755    if args.json {
756        emit(output::render_relationships_json(&report));
757    } else {
758        emit(output::render_relationships_human(&report));
759    }
760    EXIT_OK
761}
762
763// ---------------------------------------------------------------------------
764// cmd_stats
765// ---------------------------------------------------------------------------
766
767pub struct StatsArgs {
768    pub directory: String,
769    pub json: bool,
770}
771
772pub fn cmd_stats(args: &StatsArgs) -> i32 {
773    if !Path::new(&args.directory).is_dir() {
774        return usage_error(&format!("not a directory: {}", args.directory));
775    }
776    let stats = crate::stats::collect_stats(&args.directory);
777    if args.json {
778        emit(output::render_stats_json(&stats));
779    } else {
780        emit(output::render_stats_human(&stats));
781    }
782    if stats.has_meaningful_content() || stats.is_empty() {
783        EXIT_OK
784    } else {
785        EXIT_VALIDATION_FAILED
786    }
787}
788
789// ---------------------------------------------------------------------------
790// cmd_portfolio
791// ---------------------------------------------------------------------------
792
793pub struct PortfolioArgs {
794    pub directory: String,
795    pub json: bool,
796    pub top_level: bool,
797}
798
799pub fn cmd_portfolio(args: &PortfolioArgs) -> i32 {
800    if !Path::new(&args.directory).is_dir() {
801        return usage_error(&format!("not a directory: {}", args.directory));
802    }
803    let recursive = !args.top_level;
804    let items = corpus_items(&args.directory, recursive);
805    let summary = crate::portfolio::portfolio_from_corpus(&args.directory, &items, recursive);
806    if args.json {
807        emit(output::render_portfolio_json(&summary));
808    } else {
809        emit(output::render_portfolio_human(&summary));
810    }
811    EXIT_OK
812}
813
814// ---------------------------------------------------------------------------
815// cmd_index
816// ---------------------------------------------------------------------------
817
818pub struct IndexArgs {
819    pub directory: String,
820    pub json: bool,
821    pub top_level: bool,
822}
823
824/// `decided index` — the plain-walk inventory; never touches the cache.
825pub fn cmd_index(args: &IndexArgs) -> i32 {
826    if !Path::new(&args.directory).is_dir() {
827        return usage_error(&format!("not a directory: {}", args.directory));
828    }
829    let index = crate::index::build_repository_index(&args.directory, !args.top_level);
830    if args.json {
831        emit(output::render_index_json(&index));
832    } else {
833        emit(output::render_index_human(&index));
834    }
835    EXIT_OK
836}
837
838// ---------------------------------------------------------------------------
839// cmd_coverage
840// ---------------------------------------------------------------------------
841
842pub struct CoverageArgs {
843    pub directory: String,
844    pub json: bool,
845}
846
847/// Advisory, never a build failure: exit 0 on every valid run (REQ-005).
848pub fn cmd_coverage(args: &CoverageArgs) -> i32 {
849    if !Path::new(&args.directory).is_dir() {
850        return usage_error(&format!("not a directory: {}", args.directory));
851    }
852    let report = crate::coverage::analyze_coverage(&args.directory);
853    if args.json {
854        emit(output::render_coverage_json(&report));
855    } else {
856        emit(output::render_coverage_human(&report));
857    }
858    EXIT_OK
859}
860
861// ---------------------------------------------------------------------------
862// cmd_decisions_for
863// ---------------------------------------------------------------------------
864
865pub struct DecisionsForArgs {
866    pub path: String,
867    pub directory: String,
868    pub json: bool,
869    pub top_level: bool,
870}
871
872/// A query always succeeds: governed, ungoverned, and outside-repository
873/// paths all exit 0 (REQ-004); only a bad corpus directory is a usage error.
874pub fn cmd_decisions_for(args: &DecisionsForArgs) -> i32 {
875    if !Path::new(&args.directory).is_dir() {
876        return usage_error(&format!("not a directory: {}", args.directory));
877    }
878    let result = crate::retrieve::decisions_for_path(&args.directory, &args.path, !args.top_level);
879    if args.json {
880        emit(output::render_decisions_for_json(&result));
881    } else {
882        emit(output::render_decisions_for_human(&result));
883    }
884    EXIT_OK
885}
886
887// ---------------------------------------------------------------------------
888// cmd_gate
889// ---------------------------------------------------------------------------
890
891pub struct GateArgs {
892    pub directory: String,
893    pub json: bool,
894    pub sarif: bool,
895    pub top_level: bool,
896    pub code: bool,
897    pub repository: String,
898    pub base: Option<String>,
899    pub full: bool,
900}
901
902/// One enforcement entry point: validation + relationships + review under
903/// the corpus policy. Blocking findings fail (exit 1); a malformed
904/// `.decided/config.yaml` is an operational error — `decided: <message>`, exit 1
905/// (NOT the exit-2 usage class). The not-a-directory check runs BEFORE the
906/// config load, so a bad path wins exit 2 even beside a malformed config.
907pub fn cmd_gate(args: &GateArgs) -> i32 {
908    if !Path::new(&args.directory).is_dir() {
909        return usage_error(&format!("not a directory: {}", args.directory));
910    }
911    if args.code && !args.full && args.base.is_none() {
912        return usage_error("a diff base is required for --code unless --full is supplied");
913    }
914    let report = match crate::gate::build_gate_with_code(
915        &args.directory,
916        !args.top_level,
917        args.code.then_some(crate::gate::CodeGateOptions {
918            repository: &args.repository,
919            base: args.base.as_deref(),
920            full_tree: args.full,
921        }),
922    ) {
923        Ok(report) => report,
924        Err(exc) => {
925            eprintln!("decided: {}", exc.message());
926            return EXIT_VALIDATION_FAILED;
927        }
928    };
929    if args.sarif {
930        emit(output::render_gate_sarif(&report));
931    } else if args.json {
932        emit(output::render_gate_json(&report));
933    } else {
934        emit(output::render_gate_human(&report));
935    }
936    if report.ok() {
937        EXIT_OK
938    } else {
939        EXIT_VALIDATION_FAILED
940    }
941}
942
943// ---------------------------------------------------------------------------
944// cmd_sentry
945// ---------------------------------------------------------------------------
946
947pub struct SentryArgs {
948    pub directory: String,
949    pub repository: String,
950    pub base: Option<String>,
951    pub full: bool,
952    pub json: bool,
953    pub sarif: bool,
954    pub top_level: bool,
955}
956
957pub fn cmd_sentry(args: &SentryArgs) -> i32 {
958    if !Path::new(&args.directory).is_dir() {
959        return usage_error(&format!("not a directory: {}", args.directory));
960    }
961    let report = match crate::sentry::analyze(
962        &args.directory,
963        &args.repository,
964        !args.top_level,
965        args.base.as_deref(),
966        args.full,
967    ) {
968        Ok(report) => report,
969        Err(message) => return usage_error(&message),
970    };
971    if args.sarif {
972        emit(output::render_sentry_sarif(&report));
973    } else if args.json {
974        emit(output::render_sentry_json(&report));
975    } else {
976        emit(output::render_sentry_human(&report));
977    }
978    if report.ok() {
979        EXIT_OK
980    } else {
981        EXIT_VALIDATION_FAILED
982    }
983}
984
985// ---------------------------------------------------------------------------
986// cmd_herald
987// ---------------------------------------------------------------------------
988
989pub struct HeraldArgs {
990    pub directory: String,
991    pub paths_file: String,
992    pub link_base: String,
993    pub max_inline: i64,
994    pub out: String,
995    pub github_output: Option<String>,
996    pub top_level: bool,
997}
998
999pub fn cmd_herald(args: &HeraldArgs) -> i32 {
1000    if !Path::new(&args.directory).is_dir() {
1001        return usage_error(&format!("not a directory: {}", args.directory));
1002    }
1003    let paths = match std::fs::read_to_string(&args.paths_file) {
1004        Ok(text) => text.lines().map(str::trim).filter(|line| !line.is_empty()).map(str::to_string).collect::<Vec<_>>(),
1005        Err(error) => return usage_error(&format!("could not read paths file {}: {error}", args.paths_file)),
1006    };
1007    let report = crate::herald::collect(&args.directory, &paths, !args.top_level);
1008    let body = crate::herald::render(&report, &args.link_base, args.max_inline);
1009    if let Err(error) = std::fs::write(&args.out, body) {
1010        return usage_error(&format!("could not write Herald output {}: {error}", args.out));
1011    }
1012    let has_decisions = if report.has_decisions() { "true" } else { "false" };
1013    if let Some(path) = &args.github_output {
1014        use std::io::Write;
1015        let result = std::fs::OpenOptions::new()
1016            .create(true)
1017            .append(true)
1018            .open(path)
1019            .and_then(|mut file| writeln!(file, "has_decisions={has_decisions}"));
1020        if let Err(error) = result {
1021            return usage_error(&format!("could not write command output {path}: {error}"));
1022        }
1023    }
1024    emit(format!(
1025        "{} governing decision(s); has_decisions={has_decisions}",
1026        report.decisions.len()
1027    ));
1028    EXIT_OK
1029}
1030
1031// ---------------------------------------------------------------------------
1032// cmd_watchkeeper
1033// ---------------------------------------------------------------------------
1034
1035pub struct WatchkeeperArgs {
1036    pub directory: Option<String>,
1037    pub base: String,
1038    pub head: Option<String>,
1039    pub format: String, // human | json | github (choice-validated by the parser)
1040    pub json: bool,     // alias that OVERRIDES --format to json
1041    pub fail_on: String, // error | warning | none
1042    pub annotate: bool, // github format's stderr annotations (--no-annotate clears)
1043}
1044
1045/// Review product knowledge changes between two repository states. Base and
1046/// head each name an existing directory (used as-is) or a git revision
1047/// materialized via `git archive`. Failure policy (v0.12.2): `error` fails
1048/// on a review recommendation, `warning` also on any warning-severity
1049/// finding, `none` never fails. Revision/repository errors are the exit-2
1050/// usage class (`decided: <msg>`).
1051pub fn cmd_watchkeeper(args: &WatchkeeperArgs) -> i32 {
1052    let directory = match &args.directory {
1053        Some(d) => d.clone(),
1054        // `decisions/` is the conventional knowledge root — compare it when
1055        // it exists; otherwise the current directory.
1056        None => {
1057            if Path::new("decisions").is_dir() {
1058                "decisions".to_string()
1059            } else {
1060                ".".to_string()
1061            }
1062        }
1063    };
1064    if !Path::new(&directory).is_dir() {
1065        return usage_error(&format!("not a directory: {directory}"));
1066    }
1067    let report = match crate::watchkeeper::build_watchkeeper_report(
1068        &directory,
1069        &args.base,
1070        args.head.as_deref(),
1071    ) {
1072        Ok(report) => report,
1073        Err(exc) => return usage_error(exc.message()),
1074    };
1075    let output_format = if args.json { "json" } else { args.format.as_str() };
1076    if output_format == "json" {
1077        emit(output::render_watchkeeper_json(&report));
1078    } else if output_format == "github" {
1079        // stdout is the step-summary Markdown; annotations go to stderr so
1080        // `> "$GITHUB_STEP_SUMMARY"` keeps them in the step log.
1081        emit(output::render_watchkeeper_github(&report));
1082        if args.annotate {
1083            for line in output::watchkeeper_annotations(&report) {
1084                eprintln!("{line}");
1085            }
1086        }
1087    } else {
1088        emit(output::render_watchkeeper_human(&report));
1089    }
1090    if args.fail_on == "none" {
1091        return EXIT_OK;
1092    }
1093    if report.review_recommended() {
1094        return EXIT_VALIDATION_FAILED;
1095    }
1096    if args.fail_on == "warning" && report.has_warnings() {
1097        return EXIT_VALIDATION_FAILED;
1098    }
1099    EXIT_OK
1100}
1101
1102// ---------------------------------------------------------------------------
1103// cmd_doctor
1104// ---------------------------------------------------------------------------
1105
1106pub struct DoctorArgs {
1107    pub directory: String,
1108    pub json: bool,
1109    pub top_level: bool,
1110    pub hub_threshold: i64,
1111}
1112
1113/// Corpus health in one pass. Exits non-zero only on a validation or
1114/// relationship-integrity ERROR; orphan/hub/injection/unlinked/suspect
1115/// warnings exit 0 (REQ-007).
1116pub fn cmd_doctor(args: &DoctorArgs) -> i32 {
1117    if !Path::new(&args.directory).is_dir() {
1118        return usage_error(&format!("not a directory: {}", args.directory));
1119    }
1120    let report =
1121        crate::doctor::diagnose(&args.directory, !args.top_level, args.hub_threshold);
1122    if args.json {
1123        emit(output::render_doctor_json(&report));
1124    } else {
1125        emit(output::render_doctor_human(&report));
1126    }
1127    if report.ok() {
1128        EXIT_OK
1129    } else {
1130        EXIT_VALIDATION_FAILED
1131    }
1132}
1133
1134// ---------------------------------------------------------------------------
1135// cmd_review
1136// ---------------------------------------------------------------------------
1137
1138pub struct ReviewArgs {
1139    pub directory: String,
1140    pub json: bool,
1141    pub sarif: bool,
1142    pub top_level: bool,
1143    /// `--stale-after`: None when absent; Some(days) when present (const 14).
1144    pub stale_after: Option<i64>,
1145}
1146
1147pub fn cmd_review(args: &ReviewArgs) -> i32 {
1148    if !Path::new(&args.directory).is_dir() {
1149        return usage_error(&format!("not a directory: {}", args.directory));
1150    }
1151    if let Some(days) = args.stale_after {
1152        if days < 0 {
1153            return usage_error("--stale-after must be a non-negative number of days");
1154        }
1155    }
1156    let report = crate::review::build_review(&args.directory, !args.top_level, args.stale_after);
1157    if args.sarif {
1158        emit(output::render_review_sarif(&report));
1159    } else if args.json {
1160        emit(output::render_review_json(&report));
1161    } else {
1162        emit(output::render_review_human(&report));
1163    }
1164    if report.ok() {
1165        EXIT_OK
1166    } else {
1167        EXIT_VALIDATION_FAILED
1168    }
1169}
1170
1171// ---------------------------------------------------------------------------
1172// cmd_export
1173// ---------------------------------------------------------------------------
1174
1175pub struct ExportArgs {
1176    pub directory: String,
1177    pub json: bool,
1178    pub graph: bool,
1179    pub documents: bool,
1180    pub html: bool,
1181    pub okf: bool,
1182    pub agent_rules: bool,
1183    pub check: bool,
1184    pub client: Vec<String>,
1185    pub out: Option<String>,
1186}
1187
1188pub fn cmd_export(args: &ExportArgs) -> i32 {
1189    if !Path::new(&args.directory).is_dir() {
1190        return usage_error(&format!("not a directory: {}", args.directory));
1191    }
1192    // Agent-rules is a distinct mode (ADR-067) owning --out/--client/--check
1193    // and --json; it dispatches before the export-payload guards.
1194    if args.agent_rules {
1195        return cmd_agent_rules(args);
1196    }
1197    if args.check {
1198        return usage_error("--check requires --agent-rules");
1199    }
1200    if !args.client.is_empty() {
1201        return usage_error("--client requires --agent-rules");
1202    }
1203    if args.json && (args.html || args.okf) {
1204        return usage_error("--json cannot combine with --html or --okf");
1205    }
1206    if args.out.is_some() && !(args.html || args.okf) {
1207        return usage_error("--out requires --html or --okf (--json writes to stdout)");
1208    }
1209    if args.documents {
1210        emit(output::render_documents_jsonl(
1211            &crate::export::build_documents_export(&args.directory),
1212        ));
1213        return EXIT_OK;
1214    }
1215    if args.graph {
1216        emit(output::render_graph_json(&crate::export::build_graph_export(
1217            &args.directory,
1218        )));
1219        return EXIT_OK;
1220    }
1221    // OKF consumes source Markdown directly, so its projection skips the
1222    // unrelated HTML rendering used by the viewer export.
1223    if args.okf {
1224        let export = crate::export::build_okf_export(&args.directory, output::rac_version());
1225        let recency = crate::okf::artifact_recency(&args.directory, &export);
1226        let bundle = match crate::okf::render_okf_bundle(&export, &recency, &args.directory) {
1227            Ok(bundle) => bundle,
1228            Err(msg) => {
1229                // The oracle's uncaught ValueError: a Python traceback on
1230                // stderr, exit 1, nothing written. Stderr bytes are a
1231                // documented divergence; the exit code and no-write
1232                // behavior are the contract.
1233                eprintln!("ValueError: {msg}");
1234                return EXIT_VALIDATION_FAILED;
1235            }
1236        };
1237        let out = args.out.as_deref().unwrap_or("okf-bundle");
1238        for (rel, content) in &bundle {
1239            let dest = std::path::Path::new(out).join(rel);
1240            let written = dest
1241                .parent()
1242                .map(std::fs::create_dir_all)
1243                .unwrap_or(Ok(()))
1244                .and_then(|_| std::fs::write(&dest, content));
1245            if let Err(exc) = written {
1246                return usage_error(&format!("cannot write {out}: {exc}"));
1247            }
1248        }
1249        let edges = export.relationships.len();
1250        emit(format!(
1251            "wrote {out}/ \u{2014} {} artifact(s), {edges} relationship(s)",
1252            export.artifact_count()
1253        ));
1254        return EXIT_OK;
1255    }
1256    let export = crate::export::build_corpus_export(&args.directory, output::rac_version());
1257
1258    // JSON is the default mode: the payload is the product (--json a no-op).
1259    if !args.html {
1260        emit(output::render_export_json(&export));
1261        return EXIT_OK;
1262    }
1263
1264    let html = match crate::portal::render_export_html(&export) {
1265        Ok(html) => html,
1266        Err(msg) => return usage_error(&msg), // PortalSeamMissing (unreachable)
1267    };
1268    let out = args.out.as_deref().unwrap_or("lore-export.html");
1269    // Path(out).write_text: no parent mkdir — a missing directory is the
1270    // OSError path (exit 2).
1271    if let Err(exc) = std::fs::write(out, html) {
1272        return usage_error(&format!("cannot write {out}: {exc}"));
1273    }
1274    let edges = export.relationships.len();
1275    emit(format!(
1276        "wrote {out} \u{2014} {} artifact(s), {edges} relationship(s)",
1277        export.artifact_count()
1278    ));
1279    EXIT_OK
1280}
1281
1282/// `_cmd_agent_rules(args)` — `decided export --agent-rules [--check]`
1283/// (v0.21.15, ADR-067). `--check` never writes and exits 1 on drift.
1284fn cmd_agent_rules(args: &ExportArgs) -> i32 {
1285    // Invalid --client values were already rejected by the argv parser
1286    // (argparse choices), so `unknown_clients` is unreachable here.
1287    let root = crate::agent_rules::agent_rules_root(&args.directory, args.out.as_deref());
1288    let result = if args.check {
1289        crate::agent_rules::check_agent_rules(&args.directory, &root, &args.client)
1290    } else {
1291        match crate::agent_rules::generate_agent_rules(&args.directory, &root, &args.client) {
1292            Ok(result) => result,
1293            Err(exc) => return usage_error(&format!("cannot write under {root}: {exc}")),
1294        }
1295    };
1296
1297    if args.json {
1298        emit(output::render_agent_rules_json(&result));
1299    } else {
1300        emit(output::render_agent_rules_human(&result));
1301    }
1302
1303    if args.check && result.drifted() {
1304        return EXIT_VALIDATION_FAILED;
1305    }
1306    EXIT_OK
1307}
1308
1309// ---------------------------------------------------------------------------
1310// cmd_schema / cmd_templates
1311// ---------------------------------------------------------------------------
1312
1313pub struct SchemaArgs {
1314    pub schema: Option<String>,
1315    pub list: bool,
1316    pub json: bool,
1317    pub template: bool,
1318}
1319
1320pub fn cmd_schema(args: &SchemaArgs) -> i32 {
1321    let names = crate::spec::available_schemas();
1322    if args.list {
1323        if args.template {
1324            return usage_error("--template cannot be used with --list");
1325        }
1326        if args.schema.is_some() {
1327            return usage_error("schema name cannot be used with --list");
1328        }
1329        if args.json {
1330            emit(output::render_schema_list_json(&names));
1331        } else {
1332            emit(output::render_schema_list_human(&names));
1333        }
1334        return EXIT_OK;
1335    }
1336
1337    let Some(name) = &args.schema else {
1338        return usage_error("schema name required unless --list is passed");
1339    };
1340
1341    let Some(spec) = crate::spec::spec_for(name) else {
1342        // Unknown schema: multi-line blob to stderr, exit 2 (no `decided:` prefix).
1343        eprintln!("{}", output::render_unknown_schema(name, &names));
1344        return EXIT_USAGE;
1345    };
1346
1347    if args.json {
1348        emit(output::render_schema_json(spec));
1349    } else if args.template {
1350        emit(output::render_schema_template(spec));
1351    } else {
1352        emit(output::render_schema_human(spec));
1353    }
1354    EXIT_OK
1355}
1356
1357pub struct TemplatesArgs {
1358    pub json: bool,
1359}
1360
1361pub fn cmd_templates(args: &TemplatesArgs) -> i32 {
1362    let names = crate::spec::available_schemas();
1363    if args.json {
1364        emit(output::render_templates_json(&names));
1365    } else {
1366        emit(output::render_templates_human(&names));
1367    }
1368    EXIT_OK
1369}
1370
1371// ---------------------------------------------------------------------------
1372// cmd_resolve / cmd_find (PORT-CONTRACT.d/06)
1373// ---------------------------------------------------------------------------
1374
1375pub struct ResolveArgs {
1376    pub id: String,
1377    pub directory: String,
1378    pub json: bool,
1379    pub top_level: bool,
1380}
1381
1382pub fn cmd_resolve(args: &ResolveArgs) -> i32 {
1383    if !Path::new(&args.directory).is_dir() {
1384        return usage_error(&format!("not a directory: {}", args.directory));
1385    }
1386    let result = crate::resolve::resolve_artifact(&args.directory, &args.id, !args.top_level);
1387    if args.json {
1388        emit(output::render_resolve_json(&result));
1389    } else if result.outcome == crate::resolve::OUTCOME_RESOLVED {
1390        emit(output::render_resolve_human(
1391            result.artifact.as_ref().expect("resolved implies artifact"),
1392        ));
1393    } else if result.outcome == crate::resolve::OUTCOME_DUPLICATE {
1394        let found: Vec<String> = result
1395            .duplicate_paths
1396            .iter()
1397            .map(|p| format!("- {p}"))
1398            .collect();
1399        eprintln!(
1400            "decided: duplicate artifact ID: {}\n\nFound in:\n{}",
1401            args.id,
1402            found.join("\n")
1403        );
1404    } else {
1405        eprintln!("decided: artifact not found: {}", args.id);
1406    }
1407    // Not-found and duplicate identity are both repository findings (exit 1).
1408    if result.outcome == crate::resolve::OUTCOME_RESOLVED {
1409        EXIT_OK
1410    } else {
1411        EXIT_VALIDATION_FAILED
1412    }
1413}
1414
1415pub struct FindArgs {
1416    pub query: String,
1417    pub directory: String,
1418    pub artifact_type: Option<String>,
1419    pub decisions: bool,
1420    pub tags: Vec<String>,
1421    pub json: bool,
1422    pub explain: bool,
1423    pub top_level: bool,
1424    /// The live-only facet (ADR-113): drop retired matches of every type.
1425    pub live: bool,
1426    /// `--cache` / `--no-cache` (ADR-112: on by default).
1427    pub cache: bool,
1428    /// `--verify`: force the full-hash freshness floor on the cache path.
1429    pub verify: bool,
1430}
1431
1432/// `annotate_search_recency(matches, directory)` — the read-surface join
1433/// (ADR-045): git-derived staleness per match, computed AFTER ranking so the
1434/// matched set and order are unchanged. All-null outside a git repository.
1435/// Shared by `cmd_find` and the MCP `search_artifacts` tool (both surfaces
1436/// are byte-identical on this join).
1437pub fn annotate_search_recency(matches: &mut [crate::resolve::ResolvedArtifact], directory: &str) {
1438    use crate::gitinfo;
1439    if matches.is_empty() {
1440        return;
1441    }
1442    let timing_started = crate::timing::start();
1443    let threshold = crate::validate::load_freshness_threshold(directory);
1444    let reference = std::time::SystemTime::now()
1445        .duration_since(std::time::UNIX_EPOCH)
1446        .map(|d| d.as_secs() as i64)
1447        .unwrap_or(0);
1448    let repo_root = gitinfo::repository_root(Path::new(directory));
1449    let paths: Vec<PathBuf> = matches.iter().map(|m| PathBuf::from(&m.path)).collect();
1450    let committed = match &repo_root {
1451        Some(root) => gitinfo::last_committed_for_paths_in_repo(root, &paths),
1452        None => paths.into_iter().map(|path| (path, None)).collect(),
1453    };
1454    for (m, (_, last)) in matches.iter_mut().zip(committed) {
1455        let st = gitinfo::staleness(last.as_deref(), threshold, reference);
1456        m.recency = Some(crate::resolve::Recency {
1457            last_committed: st
1458                .last_committed
1459                .as_deref()
1460                .map(gitinfo::isoformat_roundtrip),
1461            age_days: st.age_days,
1462            stale: st.stale,
1463        });
1464    }
1465    crate::timing::emit_since(
1466        "git.recency_join",
1467        timing_started,
1468        &[
1469            ("matches", matches.len() as u64),
1470            ("repository", u64::from(repo_root.is_some())),
1471        ],
1472    );
1473}
1474
1475/// Serve `decided find` from the persistent index store (`_find_from_store`,
1476/// ADR-112): a warm run against an unchanged corpus reads the mapped base;
1477/// a cold run builds fresh, writes the store, and serves either the
1478/// reopened view or the fresh structures (ADR-080).
1479fn find_from_store(args: &FindArgs) -> crate::resolve::SearchResult {
1480    use crate::derived_cache::{DerivedIndexCache, ReadModel};
1481    let view = DerivedIndexCache::default().load_or_build(
1482        &args.directory,
1483        !args.top_level,
1484        args.verify,
1485    );
1486    match view {
1487        ReadModel::View(reader) => {
1488            if args.decisions {
1489                crate::read_model::store_find_decisions(&reader, &args.query)
1490            } else {
1491                crate::read_model::store_search(
1492                    &reader,
1493                    &args.query,
1494                    args.artifact_type.as_deref(),
1495                    &args.tags,
1496                    args.live,
1497                )
1498            }
1499        }
1500        ReadModel::Fresh(derived) => {
1501            if args.decisions {
1502                crate::read_model::find_decisions_in(
1503                    &derived.index_entries,
1504                    &derived.live_decision_paths,
1505                    &args.query,
1506                )
1507            } else {
1508                crate::resolve::search_index_filtered(
1509                    &derived.index_entries,
1510                    &args.query,
1511                    args.artifact_type.as_deref(),
1512                    &args.tags,
1513                    args.live,
1514                )
1515            }
1516        }
1517    }
1518}
1519
1520pub fn cmd_find(args: &FindArgs) -> i32 {
1521    if !Path::new(&args.directory).is_dir() {
1522        return usage_error(&format!("not a directory: {}", args.directory));
1523    }
1524    let mut result = if crate::derived_cache::cache_enabled(args.cache) {
1525        // Default store reuse (ADR-112): serve from the persistent index
1526        // store instead of a fresh walk, byte-identical to the walk below.
1527        find_from_store(args)
1528    } else if args.decisions {
1529        // The live decision query (ADR-067): decision type filter + the
1530        // Accepted/non-retired liveness filter; `--tag` is silently ignored.
1531        crate::resolve::find_decisions(&args.directory, &args.query, !args.top_level)
1532    } else {
1533        crate::resolve::find_artifacts(
1534            &args.directory,
1535            &args.query,
1536            args.artifact_type.as_deref(),
1537            !args.top_level,
1538            &args.tags,
1539            args.live,
1540        )
1541    };
1542    annotate_search_recency(&mut result.matches, &args.directory);
1543    let render_started = crate::timing::start();
1544    let rendered = if args.json {
1545        output::render_find_json(&result, args.explain)
1546    } else {
1547        output::render_find_human(&result, args.explain)
1548    };
1549    crate::timing::emit_since(
1550        "cli.response_serialize",
1551        render_started,
1552        &[("matches", result.matches.len() as u64), ("bytes", rendered.len() as u64)],
1553    );
1554    emit(rendered);
1555    // An empty result is a valid outcome, not an error.
1556    EXIT_OK
1557}
1558
1559pub struct RetrieveArgs {
1560    pub task: String,
1561    pub directory: String,
1562    pub scope: Option<String>,
1563    pub top_k: i64,
1564    pub budget: i64,
1565    pub all: bool,
1566    pub json: bool,
1567}
1568
1569/// `cmd_retrieve` — one-call compound grounding retrieval (ADR-113). The
1570/// `--json` face emits the budget-capped serialization; the human face renders
1571/// the same truncated payload. An empty `items` list is a valid answer.
1572pub fn cmd_retrieve(args: &RetrieveArgs) -> i32 {
1573    if !Path::new(&args.directory).is_dir() {
1574        return usage_error(&format!("not a directory: {}", args.directory));
1575    }
1576    if args.top_k < 1 {
1577        return usage_error(&format!("--top-k must be at least 1, got {}", args.top_k));
1578    }
1579    if args.budget < 1 {
1580        return usage_error(&format!("--budget must be at least 1, got {}", args.budget));
1581    }
1582    let payload = crate::retrieve::retrieve_grounding(
1583        &args.directory,
1584        &args.task,
1585        args.scope.as_deref(),
1586        args.top_k,
1587        args.budget,
1588        !args.all,
1589    );
1590    let serialized = crate::budget::serialize(&payload, args.budget);
1591    if args.json {
1592        emit(serialized);
1593    } else {
1594        // The oracle renders from json.loads(serialized) — the truncated shape.
1595        let truncated: serde_json::Value =
1596            serde_json::from_str(&serialized).expect("serialized payload is valid JSON");
1597        emit(output::render_retrieve_human(&truncated));
1598    }
1599    EXIT_OK
1600}
1601
1602// ---------------------------------------------------------------------------
1603// cmd_mcp_stats / cmd_usage / cmd_telemetry (local-state reporting,
1604// ADR-040/041/046, ADR-086 — PORT-CONTRACT.d/14)
1605// ---------------------------------------------------------------------------
1606
1607/// The oracle CRASHES on a non-UTF-8 state log (`read_text` raises
1608/// `UnicodeDecodeError`; the readers catch only `OSError`): traceback to
1609/// stderr, EMPTY stdout, exit 1. Bug-for-bug mirror; the stderr text is
1610/// out of parity scope.
1611fn state_log_crash() -> i32 {
1612    eprintln!("decided-rs: state log is not valid UTF-8");
1613    EXIT_VALIDATION_FAILED
1614}
1615
1616pub struct McpStatsArgs {
1617    pub json: bool,
1618    pub share: bool,
1619}
1620
1621/// `decided mcp-stats` — Guide-only read-back. An empty or missing log is a
1622/// valid answer (telemetry is off by default), like `find` with no
1623/// matches: exit 0 for every log state.
1624pub fn cmd_mcp_stats(args: &McpStatsArgs) -> i32 {
1625    let summary = match crate::telemetry::summarize() {
1626        Ok(summary) => summary,
1627        Err(_) => return state_log_crash(),
1628    };
1629    if args.share {
1630        emit(crate::telemetry::share_url(&summary));
1631    } else if args.json {
1632        emit(output::render_mcp_stats_json(&summary));
1633    } else {
1634        emit(output::render_mcp_stats_human(&summary));
1635    }
1636    EXIT_OK
1637}
1638
1639pub struct UsageArgs {
1640    pub json: bool,
1641    pub share: bool,
1642}
1643
1644/// `decided usage` — unified read-back over the CLI-usage log and the Guide
1645/// log (ADR-046). No consent gate on reads; exit 0 for every log state.
1646/// The CLI log is read FIRST (a bad usage log crashes before the Guide
1647/// log is touched, like the oracle's statement order).
1648pub fn cmd_usage(args: &UsageArgs) -> i32 {
1649    let summary = match crate::usage::summarize_usage() {
1650        Ok(summary) => summary,
1651        Err(_) => return state_log_crash(),
1652    };
1653    let guide = match crate::telemetry::summarize() {
1654        Ok(guide) => guide,
1655        Err(_) => return state_log_crash(),
1656    };
1657    if args.share {
1658        emit(crate::usage::share_url(&summary, &guide));
1659    } else if args.json {
1660        emit(output::render_usage_json(&summary, &guide));
1661    } else {
1662        emit(output::render_usage_human(&summary, &guide));
1663    }
1664    EXIT_OK
1665}
1666
1667pub struct SkillArgs {
1668    /// Validated positional choice: `install` or `list`.
1669    pub action: String,
1670    /// Optional skill name (install: one skill; absent: all, all-or-nothing).
1671    pub name: Option<String>,
1672    /// Target directory (argparse default ".").
1673    pub dir: String,
1674    pub json: bool,
1675}
1676
1677/// `decided skill <action> [name] [--dir DIR] [--json]` — list or install the
1678/// bundled Claude Code agent skills. The `--dir` not-a-directory check runs
1679/// BEFORE the unknown-name check (skill brief, landmine 5).
1680pub fn cmd_skill(args: &SkillArgs) -> i32 {
1681    use crate::skill::{install_skills, SkillInstallError};
1682
1683    if args.action == "list" {
1684        if args.name.is_some() {
1685            return usage_error("skill list takes no skill name");
1686        }
1687        if args.json {
1688            emit(output::render_skill_list_json());
1689        } else {
1690            emit(output::render_skill_list_human());
1691        }
1692        return EXIT_OK;
1693    }
1694
1695    if !Path::new(&args.dir).is_dir() {
1696        return usage_error(&format!("not a directory: {}", args.dir));
1697    }
1698    let installation = match install_skills(&args.dir, args.name.as_deref()) {
1699        Ok(installation) => installation,
1700        Err(SkillInstallError::NotFound(message)) => return usage_error(&message),
1701        Err(SkillInstallError::FileExists(message)) | Err(SkillInstallError::Io(message)) => {
1702            // Refused (never overwrites) or operational failure — exit 1
1703            // with the `decided: ` prefix, every existing file untouched.
1704            eprintln!("decided: {message}");
1705            return EXIT_VALIDATION_FAILED;
1706        }
1707    };
1708    if args.json {
1709        emit(output::render_skill_install_json(&installation));
1710    } else {
1711        emit(output::render_skill_install_human(&installation));
1712    }
1713    EXIT_OK
1714}
1715
1716pub struct HookArgs {
1717    /// Validated positional choice: `install` or `list`.
1718    pub action: String,
1719    /// Validated `--style` choice (argparse default `post-commit`).
1720    pub style: String,
1721    /// Target directory (argparse default ".").
1722    pub dir: String,
1723    pub json: bool,
1724}
1725
1726/// `decided hook <action> [--style STYLE] [--dir DIR] [--json]` — list or
1727/// install the bundled git hooks. `list` ignores `--style`/`--dir`; an
1728/// invalid style never reaches here (argparse choices fire first).
1729pub fn cmd_hook(args: &HookArgs) -> i32 {
1730    use crate::hook::{install_hook, HookInstallError};
1731
1732    if args.action == "list" {
1733        if args.json {
1734            emit(output::render_hook_list_json());
1735        } else {
1736            emit(output::render_hook_list_human());
1737        }
1738        return EXIT_OK;
1739    }
1740
1741    if !Path::new(&args.dir).is_dir() {
1742        return usage_error(&format!("not a directory: {}", args.dir));
1743    }
1744    let installation = match install_hook(&args.dir, &args.style) {
1745        Ok(installation) => installation,
1746        Err(HookInstallError::NotAGitWorkTree(message)) => return usage_error(&message),
1747        Err(HookInstallError::FileExists(message)) | Err(HookInstallError::Io(message)) => {
1748            eprintln!("decided: {message}");
1749            return EXIT_VALIDATION_FAILED;
1750        }
1751    };
1752    if args.json {
1753        emit(output::render_hook_install_json(&installation));
1754    } else {
1755        emit(output::render_hook_install_human(&installation));
1756    }
1757    EXIT_OK
1758}
1759
1760pub struct EvalArgs {
1761    pub check: bool,
1762    pub update_baseline: bool,
1763    pub json: bool,
1764    pub root: String,
1765    pub queries: String,
1766    pub baseline: String,
1767    pub config: String,
1768}
1769
1770/// `decided eval [--check | --update-baseline] [--json] ...` — score retrieval
1771/// against the fixture benchmark, or gate against the baseline (ADR-066).
1772/// Modes win over `--json` (eval brief, landmine 7); every `EvalUsageError`
1773/// exits 2 with a `decided eval: ` stderr prefix — including a missing baseline
1774/// under `--check`, discovered only AFTER the benchmark has run (statement
1775/// order mirrors the oracle's single try block).
1776pub fn cmd_eval(args: &EvalArgs) -> i32 {
1777    use crate::eval;
1778
1779    let fail = |err: eval::EvalUsageError| -> i32 {
1780        eprintln!("decided eval: {}", err.0);
1781        EXIT_USAGE
1782    };
1783    let scorecard = match eval::run_eval(&args.root, &args.queries) {
1784        Ok(scorecard) => scorecard,
1785        Err(err) => return fail(err),
1786    };
1787    if args.update_baseline {
1788        let payload = eval::render_metrics_json(&scorecard.metrics) + "\n";
1789        if let Err(e) = std::fs::write(&args.baseline, payload) {
1790            // The oracle lets the OSError escape as a traceback (exit 1);
1791            // fail with the same code without the traceback noise.
1792            eprintln!("decided: cannot write {}: {e}", args.baseline);
1793            return EXIT_VALIDATION_FAILED;
1794        }
1795        emit(format!("decided eval: baseline updated -> {}", args.baseline));
1796        return EXIT_OK;
1797    }
1798    if args.check {
1799        let baseline = match eval::load_baseline(&args.baseline) {
1800            Ok(baseline) => baseline,
1801            Err(err) => return fail(err),
1802        };
1803        let config = match eval::load_config(&args.config) {
1804            Ok(config) => config,
1805            Err(err) => return fail(err),
1806        };
1807        let failures = eval::evaluate_gate(&scorecard.metrics, &baseline, &config);
1808        if !failures.is_empty() {
1809            for failure in &failures {
1810                emit(failure.render());
1811            }
1812            return EXIT_VALIDATION_FAILED;
1813        }
1814        emit("decided eval: gate PASS".to_string());
1815        return EXIT_OK;
1816    }
1817    if args.json {
1818        emit(eval::render_scorecard_json(&scorecard));
1819    } else {
1820        emit(eval::render_scorecard_human(&scorecard));
1821    }
1822    EXIT_OK
1823}
1824
1825// ---------------------------------------------------------------------------
1826// cmd_new / cmd_init / cmd_quickstart / cmd_migrate / cmd_rename
1827// (scaffold writes — PORT-CONTRACT.d/16)
1828// ---------------------------------------------------------------------------
1829
1830pub struct NewArgs {
1831    pub artifact_type: String,
1832    pub output_path: String,
1833    pub json: bool,
1834}
1835
1836/// `decided new <type> <output_path>` — create one artifact from its canonical
1837/// template. Usage errors (bad type, exists, missing parent, no repo
1838/// config) exit 2; operational errors (malformed config, id exhaustion)
1839/// exit 1 — all stderr `decided: <msg>`.
1840pub fn cmd_new(args: &NewArgs) -> i32 {
1841    use crate::scaffold::ScaffoldError;
1842    let created = match crate::scaffold::create_artifact(&args.artifact_type, &args.output_path) {
1843        Ok(created) => created,
1844        Err(
1845            e @ (ScaffoldError::TemplateNotFound(_)
1846            | ScaffoldError::OutputPathExists(_)
1847            | ScaffoldError::OutputDirectoryMissing(_)
1848            | ScaffoldError::MissingRepositoryConfig(_)),
1849        ) => return usage_error(e.message()),
1850        Err(e) => {
1851            eprintln!("decided: {}", e.message());
1852            return EXIT_VALIDATION_FAILED;
1853        }
1854    };
1855    if args.json {
1856        emit(output::render_new_json(&created));
1857    } else {
1858        emit(output::render_new_human(&created));
1859    }
1860    EXIT_OK
1861}
1862
1863/// `_maybe_ask_usage_sharing()` — the CLI's only interactive prompt
1864/// (ADR-041): a real TTY on BOTH ends, no prior answer; either answer is
1865/// persisted so the question is asked at most once per machine. Under the
1866/// parity harness stdio is piped, so this never fires there; the gate and
1867/// bytes are mirrored for real-TTY runs and the answer handling is
1868/// unit-tested below.
1869fn maybe_ask_usage_sharing() {
1870    use std::io::{BufRead, IsTerminal, Write};
1871    if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal())
1872        || crate::consent::consent_recorded()
1873    {
1874        return;
1875    }
1876    {
1877        let mut out = std::io::stdout().lock();
1878        let _ = out.write_all("\nShare anonymous usage to help shape AsDecided? [y/N] ".as_bytes());
1879        let _ = out.flush();
1880    }
1881    let mut answer = String::new();
1882    let _ = std::io::stdin().lock().read_line(&mut answer); // EOF -> empty
1883    if let Some(message) = handle_share_answer(&answer) {
1884        emit(message.to_string());
1885    }
1886}
1887
1888/// The prompt's answer handling: `y`/`yes` (trimmed, lowercased) opts in
1889/// and returns the confirmation line; anything else (including EOF/empty)
1890/// declines silently.
1891fn handle_share_answer(answer: &str) -> Option<&'static str> {
1892    if share_answer_is_yes(answer) {
1893        crate::consent::opt_in();
1894        Some(
1895            "Sharing preference recorded locally. This native build has no outbound \
1896             telemetry sender; 'decided telemetry status' shows the local state.",
1897        )
1898    } else {
1899        crate::consent::decline();
1900        None
1901    }
1902}
1903
1904/// `answer.strip().lower() in ("y", "yes")` — the pure classification the
1905/// prompt applies (unit-tested; the prompt itself is TTY-gated and outside
1906/// the piped parity harness's reach).
1907fn share_answer_is_yes(answer: &str) -> bool {
1908    matches!(
1909        crate::pycompat::py_strip(answer).to_lowercase().as_str(),
1910        "y" | "yes"
1911    )
1912}
1913
1914#[cfg(test)]
1915#[allow(clippy::items_after_test_module)]
1916mod share_prompt_tests {
1917    use super::share_answer_is_yes;
1918
1919    /// The ADR-041 prompt accepts exactly y/yes (any case, surrounding
1920    /// whitespace stripped); empty input and EOF mean No.
1921    #[test]
1922    fn share_answer_classification() {
1923        for yes in ["y", "Y", "yes", "YES", "  y  ", "Yes\n"] {
1924            assert!(share_answer_is_yes(yes), "{yes:?} should opt in");
1925        }
1926        for no in ["", "\n", "n", "no", "yess", "y e s", "ok"] {
1927            assert!(!share_answer_is_yes(no), "{no:?} should decline");
1928        }
1929    }
1930}
1931
1932pub struct InitArgs {
1933    pub directory: String,
1934    pub key: String,
1935    /// argparse-choice-validated ticketing provider.
1936    pub ticketing: Option<String>,
1937    /// argparse-choice-validated profile name.
1938    pub profile: Option<String>,
1939    /// Org endpoint URL (ADR-117); http(s)-validated in the service layer.
1940    pub org_endpoint: Option<String>,
1941    pub json: bool,
1942}
1943
1944/// `decided init [directory] [--key KEY] [--ticketing PROVIDER] [--profile
1945/// NAME]` — establish (or confirm) the repository identity namespace.
1946/// Invalid key exits 2; conflict/malformed config exit 1. A successful
1947/// non-JSON init may ask the one-time sharing question (TTY-gated).
1948pub fn cmd_init(args: &InitArgs) -> i32 {
1949    use crate::scaffold::ScaffoldError;
1950    if !Path::new(&args.directory).is_dir() {
1951        return usage_error(&format!("not a directory: {}", args.directory));
1952    }
1953    let result = match crate::scaffold::init_repository(
1954        &args.directory,
1955        &args.key,
1956        args.ticketing.as_deref(),
1957        args.profile.as_deref(),
1958        args.org_endpoint.as_deref(),
1959    ) {
1960        Ok(result) => result,
1961        Err(
1962            e @ (ScaffoldError::InvalidRepositoryKey(_) | ScaffoldError::InvalidOrgEndpoint(_)),
1963        ) => return usage_error(e.message()),
1964        Err(e) => {
1965            eprintln!("decided: {}", e.message());
1966            return EXIT_VALIDATION_FAILED;
1967        }
1968    };
1969    if args.json {
1970        emit(output::render_init_json(&result));
1971    } else {
1972        emit(output::render_init_human(&result));
1973        maybe_ask_usage_sharing();
1974    }
1975    EXIT_OK
1976}
1977
1978pub struct QuickstartArgs {
1979    pub directory: String,
1980    pub key: String,
1981    /// Free-string starter type (validated by the template registry).
1982    pub artifact_type: String,
1983    pub json: bool,
1984}
1985
1986/// `decided quickstart [directory] [--key KEY] [--type TYPE]` — identity plus
1987/// one starter artifact in one step (ADR-044). Exit routing mirrors the
1988/// oracle's except ladder: bad type / bad key / missing parent are usage
1989/// (2); a non-empty corpus, key conflict, or occupied starter path are
1990/// refusals (1); operational errors are 1.
1991pub fn cmd_quickstart(args: &QuickstartArgs) -> i32 {
1992    use crate::scaffold::ScaffoldError;
1993    if !Path::new(&args.directory).is_dir() {
1994        return usage_error(&format!("not a directory: {}", args.directory));
1995    }
1996    let result =
1997        match crate::scaffold::quickstart(&args.directory, &args.key, &args.artifact_type) {
1998            Ok(result) => result,
1999            Err(
2000                e @ (ScaffoldError::TemplateNotFound(_)
2001                | ScaffoldError::InvalidRepositoryKey(_)
2002                | ScaffoldError::OutputDirectoryMissing(_)),
2003            ) => return usage_error(e.message()),
2004            Err(e) => {
2005                eprintln!("decided: {}", e.message());
2006                return EXIT_VALIDATION_FAILED;
2007            }
2008        };
2009    if args.json {
2010        emit(output::render_quickstart_json(&result));
2011    } else {
2012        emit(output::render_quickstart_human(&result));
2013        maybe_ask_usage_sharing();
2014    }
2015    EXIT_OK
2016}
2017
2018pub struct MigrateArgs {
2019    /// Validated positional choice (only `metadata` exists).
2020    pub target: String,
2021    pub directory: String,
2022    pub dry_run: bool,
2023    pub top_level: bool,
2024    pub json: bool,
2025}
2026
2027/// `decided migrate metadata <directory> [--dry-run]` — canonical frontmatter
2028/// identity for every recognized legacy artifact. A completed migration
2029/// (or dry run) always exits 0 — nothing to migrate is a valid outcome.
2030pub fn cmd_migrate(args: &MigrateArgs) -> i32 {
2031    use crate::scaffold::ScaffoldError;
2032    if !Path::new(&args.directory).is_dir() {
2033        return usage_error(&format!("not a directory: {}", args.directory));
2034    }
2035    if args.target == "layout" {
2036        return migrate_layout(args);
2037    }
2038    let report = match crate::scaffold::migrate_metadata(
2039        &args.directory,
2040        args.dry_run,
2041        !args.top_level,
2042    ) {
2043        Ok(report) => report,
2044        Err(e @ ScaffoldError::MissingRepositoryConfig(_)) => return usage_error(e.message()),
2045        Err(e) => {
2046            eprintln!("decided: {}", e.message());
2047            return EXIT_VALIDATION_FAILED;
2048        }
2049    };
2050    if args.json {
2051        emit(output::render_migrate_json(&report));
2052    } else {
2053        emit(output::render_migrate_human(&report));
2054    }
2055    EXIT_OK
2056}
2057
2058/// Explicit one-way repository layout cutover. Nothing is inferred or moved
2059/// during ordinary commands: operators first inspect `--dry-run`, then apply.
2060fn migrate_layout(args: &MigrateArgs) -> i32 {
2061    let root = Path::new(&args.directory);
2062    let moves = [
2063        (root.join(".rac"), root.join(".decided")),
2064        (root.join("rac"), root.join("decisions")),
2065    ];
2066    let planned: Vec<_> = moves
2067        .iter()
2068        .filter(|(from, _)| from.exists())
2069        .collect();
2070    for (_, to) in &planned {
2071        if to.exists() {
2072            return usage_error(&format!(
2073                "refusing layout migration because destination already exists: {}",
2074                to.display()
2075            ));
2076        }
2077    }
2078    if !args.dry_run {
2079        for (from, to) in &planned {
2080            if let Err(error) = std::fs::rename(from, to) {
2081                eprintln!(
2082                    "decided: cannot migrate {} to {}: {error}",
2083                    from.display(),
2084                    to.display()
2085                );
2086                return EXIT_VALIDATION_FAILED;
2087            }
2088        }
2089    }
2090    if args.json {
2091        let operations: Vec<_> = planned
2092            .iter()
2093            .map(|(from, to)| {
2094                serde_json::json!({"from": from, "to": to})
2095            })
2096            .collect();
2097        emit(
2098            serde_json::to_string_pretty(&serde_json::json!({
2099                "directory": args.directory,
2100                "dry_run": args.dry_run,
2101                "operations": operations,
2102            }))
2103            .expect("layout migration result is serializable"),
2104        );
2105    } else if planned.is_empty() {
2106        emit("No legacy .rac or rac layout found.".to_string());
2107    } else {
2108        let verb = if args.dry_run { "Would move" } else { "Moved" };
2109        for (from, to) in planned {
2110            emit(format!("{verb} {} -> {}", from.display(), to.display()));
2111        }
2112    }
2113    EXIT_OK
2114}
2115
2116pub struct RenameArgs {
2117    pub old: String,
2118    pub new: String,
2119    pub directory: String,
2120    pub apply: bool,
2121    pub top_level: bool,
2122    pub json: bool,
2123}
2124
2125/// `decided rename <old> <new> <directory> [--apply] [--top-level]` — compute
2126/// (and optionally apply) the corpus-wide rename edit set. Refusals exit 1
2127/// with the human rendering on STDERR but the JSON plan on STDOUT; a valid
2128/// dry run and a successful apply exit 0.
2129pub fn cmd_rename(args: &RenameArgs) -> i32 {
2130    if !Path::new(&args.directory).is_dir() {
2131        return usage_error(&format!("not a directory: {}", args.directory));
2132    }
2133    let plan =
2134        crate::rename::compute_rename(&args.directory, &args.old, &args.new, !args.top_level);
2135
2136    if !plan.ok {
2137        if args.json {
2138            emit(output::render_rename_json(&plan));
2139        } else {
2140            eprintln!("{}", output::render_rename_human(&plan));
2141        }
2142        return EXIT_VALIDATION_FAILED;
2143    }
2144
2145    if !args.apply {
2146        if args.json {
2147            emit(output::render_rename_json(&plan));
2148        } else {
2149            emit(output::render_rename_human(&plan));
2150        }
2151        return EXIT_OK;
2152    }
2153
2154    let result = match crate::rename::apply_rename(&plan) {
2155        Ok(result) => result,
2156        Err(message) => {
2157            // The oracle's stale-plan ValueError escapes as a traceback
2158            // (exit 1, empty stdout); same code, readable stderr.
2159            eprintln!("{message}");
2160            return EXIT_VALIDATION_FAILED;
2161        }
2162    };
2163    if args.json {
2164        emit(output::render_rename_result_json(&result));
2165    } else {
2166        emit(output::render_rename_result_human(&result));
2167    }
2168    EXIT_OK
2169}
2170
2171pub struct TelemetryArgs {
2172    /// Validated positional choice; argparse default is `status`.
2173    pub action: String,
2174    pub enterprise: bool,
2175    pub unlock: bool,
2176}
2177
2178/// `decided telemetry [on|off|status] [--enterprise] [--unlock]` — show or
2179/// change sharing consent (ADR-041) and the enterprise hard-lock
2180/// (ADR-086). Flag validation order is pinned: enterprise/unlock with a
2181/// non-`off` action first, then unlock-without-enterprise, then the
2182/// opt-in-while-locked refusal — three distinct exit-2 usage errors.
2183pub fn cmd_telemetry(args: &TelemetryArgs) -> i32 {
2184    if (args.enterprise || args.unlock) && args.action != "off" {
2185        return usage_error("--enterprise/--unlock are only valid with 'decided telemetry off'");
2186    }
2187    if args.unlock && !args.enterprise {
2188        return usage_error(
2189            "--unlock requires --enterprise (use 'decided telemetry off --enterprise --unlock')",
2190        );
2191    }
2192
2193    if args.action == "on" {
2194        if crate::consent::load_consent().enterprise_locked {
2195            return usage_error(
2196                "cannot opt in while the enterprise telemetry lock is set; remove it with \
2197                 'decided telemetry off --enterprise --unlock' first (ADR-086).",
2198            );
2199        }
2200        let record = crate::consent::opt_in();
2201        emit(format!(
2202            "Sharing preference recorded locally. Install id: {}",
2203            record.install_id
2204        ));
2205        emit(
2206            "This native build has no outbound telemetry sender; nothing is sent. \
2207             Local usage read-back and explicit share URLs remain available (ADR-131)."
2208                .to_string(),
2209        );
2210        #[allow(clippy::const_is_empty)]
2211        if crate::consent::POSTHOG_API_KEY.is_empty() {
2212            emit("Endpoint key: not configured — nothing is sent.".to_string());
2213        }
2214    } else if args.action == "off" {
2215        if args.enterprise && args.unlock {
2216            crate::consent::enterprise_unlock();
2217            emit(
2218                "Enterprise lock removed. Sharing stays off; re-enable with \
2219                 'decided telemetry on' (ADR-086)."
2220                    .to_string(),
2221            );
2222        } else if args.enterprise {
2223            crate::consent::enterprise_lock();
2224            emit(
2225                "Sharing off and enterprise-locked. Outbound sharing is disabled \
2226                 and cannot be re-enabled until unlocked with \
2227                 'decided telemetry off --enterprise --unlock' (ADR-086)."
2228                    .to_string(),
2229            );
2230        } else {
2231            crate::consent::opt_out();
2232            emit("Sharing off. Nothing will be sent.".to_string());
2233        }
2234    } else {
2235        // status — `Sharing:` tri-state precedence: the enterprise lock
2236        // wins over sharing; the 5th line is locked-note XOR sharing-note.
2237        let status = crate::consent::consent_status();
2238        let sharing = if status.enterprise_locked {
2239            "locked (enterprise)"
2240        } else if status.sharing {
2241            "on"
2242        } else {
2243            "off"
2244        };
2245        emit(format!("Sharing: {sharing}"));
2246        emit(format!(
2247            "Install id: {}",
2248            if status.install_id.is_empty() {
2249                "(none)"
2250            } else {
2251                &status.install_id
2252            }
2253        ));
2254        emit(format!(
2255            "Consented at: {}",
2256            if status.consented_at.is_empty() {
2257                "(never)"
2258            } else {
2259                &status.consented_at
2260            }
2261        ));
2262        emit(format!("Consent file: {}", status.path));
2263        if status.enterprise_locked {
2264            emit(
2265                "Enterprise lock: on \u{2014} outbound sharing is disabled. Remove with \
2266                 'decided telemetry off --enterprise --unlock' (ADR-086)."
2267                    .to_string(),
2268            );
2269        } else if status.sharing {
2270            emit(
2271                "Local sharing preference: enabled. This native build has no outbound \
2272                 telemetry sender; no paths, queries, or content leave the machine (ADR-131)."
2273                    .to_string(),
2274            );
2275        }
2276        if !status.endpoint_configured {
2277            emit("Endpoint key: not configured \u{2014} nothing is sent.".to_string());
2278        }
2279    }
2280    EXIT_OK
2281}