1use 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
23pub 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 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
45pub 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
97pub 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 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
161fn 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
179fn 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
185pub 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
198pub 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 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 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; };
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
384pub 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
401pub 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 pub cache: bool,
413 pub verify: bool,
415}
416
417fn py_path_str(p: &str) -> String {
419 normalize_root(p)
420}
421
422fn 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
435fn 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 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 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 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
547pub struct DiffArgs {
552 pub old: String,
553 pub new: String,
554 pub json: bool,
555}
556
557pub fn cmd_diff(args: &DiffArgs) -> i32 {
558 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
576fn 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
590fn 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 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 it first with: decided ingest {target}"
607 )));
608 }
609 match std::fs::read(target) {
610 Ok(bytes) => match String::from_utf8(bytes) {
611 Ok(text) => Ok(text),
612 Err(e) => {
616 eprintln!(
617 "UnicodeDecodeError: 'utf-8' codec can't decode input: {e}"
618 );
619 Err(EXIT_VALIDATION_FAILED)
620 }
621 },
622 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 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 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 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 EXIT_OK
689}
690
691pub 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: decided ingest {}",
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 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
763pub 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
789pub 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
814pub struct IndexArgs {
819 pub directory: String,
820 pub json: bool,
821 pub top_level: bool,
822}
823
824pub 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
838pub struct CoverageArgs {
843 pub directory: String,
844 pub json: bool,
845}
846
847pub 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
861pub struct DecisionsForArgs {
866 pub path: String,
867 pub directory: String,
868 pub json: bool,
869 pub top_level: bool,
870}
871
872pub 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
887pub 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
902pub 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
943pub 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
985pub 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
1031pub struct WatchkeeperArgs {
1036 pub directory: Option<String>,
1037 pub base: String,
1038 pub head: Option<String>,
1039 pub format: String, pub json: bool, pub fail_on: String, pub annotate: bool, }
1044
1045pub fn cmd_watchkeeper(args: &WatchkeeperArgs) -> i32 {
1052 let directory = match &args.directory {
1053 Some(d) => d.clone(),
1054 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 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
1102pub struct DoctorArgs {
1107 pub directory: String,
1108 pub json: bool,
1109 pub top_level: bool,
1110 pub hub_threshold: i64,
1111}
1112
1113pub 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
1134pub struct ReviewArgs {
1139 pub directory: String,
1140 pub json: bool,
1141 pub sarif: bool,
1142 pub top_level: bool,
1143 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
1171pub 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 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 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 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 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), };
1268 let out = args.out.as_deref().unwrap_or("lore-export.html");
1269 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
1282fn cmd_agent_rules(args: &ExportArgs) -> i32 {
1285 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
1309pub 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 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
1371pub 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 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 pub live: bool,
1426 pub cache: bool,
1428 pub verify: bool,
1430}
1431
1432pub 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
1475fn 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 find_from_store(args)
1528 } else if args.decisions {
1529 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 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
1569pub 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 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
1602fn 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
1621pub 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
1644pub 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 pub action: String,
1670 pub name: Option<String>,
1672 pub dir: String,
1674 pub json: bool,
1675}
1676
1677pub 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 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 pub action: String,
1719 pub style: String,
1721 pub dir: String,
1723 pub json: bool,
1724}
1725
1726pub 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
1770pub 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 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
1825pub struct NewArgs {
1831 pub artifact_type: String,
1832 pub output_path: String,
1833 pub json: bool,
1834}
1835
1836pub 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
1863fn 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); if let Some(message) = handle_share_answer(&answer) {
1884 emit(message.to_string());
1885 }
1886}
1887
1888fn handle_share_answer(answer: &str) -> Option<&'static str> {
1892 if share_answer_is_yes(answer) {
1893 crate::consent::opt_in();
1894 Some(
1895 "Sharing on \u{2014} one anonymous daily ping. 'decided telemetry status' \
1896 shows exactly what; 'decided telemetry off' stops it.",
1897 )
1898 } else {
1899 crate::consent::decline();
1900 None
1901 }
1902}
1903
1904fn 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 #[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 pub ticketing: Option<String>,
1937 pub profile: Option<String>,
1939 pub org_endpoint: Option<String>,
1941 pub json: bool,
1942}
1943
1944pub 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 pub artifact_type: String,
1983 pub json: bool,
1984}
1985
1986pub 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 pub target: String,
2021 pub directory: String,
2022 pub dry_run: bool,
2023 pub top_level: bool,
2024 pub json: bool,
2025}
2026
2027pub 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
2058fn 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
2125pub 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 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 pub action: String,
2174 pub enterprise: bool,
2175 pub unlock: bool,
2176}
2177
2178pub 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!("Sharing on. Install id: {}", record.install_id));
2202 emit(
2203 "One anonymous daily ping: install id, decided version, active-repo count. \
2204 Never paths, queries, or content (ADR-041)."
2205 .to_string(),
2206 );
2207 #[allow(clippy::const_is_empty)]
2211 if crate::consent::POSTHOG_API_KEY.is_empty() {
2212 emit("Note: this build has no PostHog key configured; nothing will be 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. The daily ping is forced off \
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 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} the daily ping is forced off. Remove with \
2266 'decided telemetry off --enterprise --unlock' (ADR-086)."
2267 .to_string(),
2268 );
2269 } else if status.sharing {
2270 emit(
2271 "Shared daily: install id, decided version, active-repo count. \
2272 Never paths, queries, or content (ADR-041)."
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}