use std::path::{Path, PathBuf};
use crate::output;
use crate::parse::{parse_file, parse_text, Artifact, Issue};
use crate::relationships::{
build_relationship_report, build_relationship_report_file, corpus_items,
validate_document_against_corpus, validate_relationships, validate_relationships_file,
RelationshipIssue,
};
use crate::validate::{
apply_overrides, check_okf_conformance, has_errors, load_overrides, load_ticketing_provider,
validate, validate_product, OkfConformanceReport, OkfEntry,
};
use crate::walk::normalize_root;
pub const EXIT_OK: i32 = 0;
pub const EXIT_VALIDATION_FAILED: i32 = 1;
pub const EXIT_USAGE: i32 = 2;
pub const STATUS_VALID: &str = "valid";
pub const STATUS_INVALID: &str = "invalid";
pub const STATUS_SKIPPED: &str = "skipped";
fn usage_error(message: &str) -> i32 {
eprintln!("decided: {message}");
EXIT_USAGE
}
fn emit(text: String) {
use std::io::Write;
let payload = crate::pycompat::encode_stdout_surrogateescape(&text);
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(&payload);
let _ = stdout.write_all(b"\n");
let _ = stdout.flush();
}
pub struct FileValidation {
pub path: String,
pub artifact_type: String,
pub status: &'static str,
pub issues: Vec<Issue>,
}
pub struct DirectoryValidation {
pub directory: String,
pub recursive: bool,
pub files: Vec<FileValidation>,
pub okf: Option<OkfConformanceReport>,
}
impl DirectoryValidation {
pub fn checked(&self) -> usize {
self.files.iter().filter(|f| f.status != STATUS_SKIPPED).count()
}
pub fn valid(&self) -> usize {
self.files.iter().filter(|f| f.status == STATUS_VALID).count()
}
pub fn invalid(&self) -> usize {
self.files.iter().filter(|f| f.status == STATUS_INVALID).count()
}
pub fn skipped(&self) -> usize {
self.files.iter().filter(|f| f.status == STATUS_SKIPPED).count()
}
pub fn ok(&self) -> bool {
self.invalid() == 0 && self.okf.as_ref().map(|o| o.ok()).unwrap_or(true)
}
}
pub struct StdinCorpusValidation {
pub source_path: String,
pub structural_issues: Vec<Issue>,
pub relationship_issues: Vec<RelationshipIssue>,
}
impl StdinCorpusValidation {
pub fn ok(&self) -> bool {
!has_errors(&self.structural_issues) && self.relationship_issues.is_empty()
}
}
pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidation {
let entries = corpus_items(directory, recursive);
let overrides = load_overrides(directory);
let provider = load_ticketing_provider(directory);
use rayon::prelude::*;
let files: Vec<FileValidation> = entries
.par_iter()
.map(|item| {
let artifact_type = item
.spec
.map(|s| s.name.clone())
.unwrap_or_else(|| "unknown".to_string());
if item.spec.is_none() {
return FileValidation {
path: item.path.clone(),
artifact_type,
status: STATUS_SKIPPED,
issues: Vec::new(),
};
}
let issues = apply_overrides(
validate(&item.artifact, provider.as_deref(), Some(&artifact_type)),
&artifact_type,
&overrides,
);
let status = if has_errors(&issues) {
STATUS_INVALID
} else {
STATUS_VALID
};
FileValidation {
path: item.path.clone(),
artifact_type,
status,
issues,
}
})
.collect();
let okf_entries: Vec<OkfEntry> = entries
.iter()
.map(|item| OkfEntry {
path: &item.path,
artifact_type: item
.spec
.map(|s| s.name.as_str())
.unwrap_or("unknown"),
file_name: item.path.rsplit('/').next().unwrap_or(&item.path),
})
.collect();
let okf = check_okf_conformance(&okf_entries, &overrides);
DirectoryValidation {
directory: directory.to_string(),
recursive,
files,
okf: Some(okf),
}
}
fn config_fingerprint(directory: &str) -> String {
let mut hasher = crate::sha256::Sha256::new();
match crate::validate::find_config_file(directory) {
None => hasher.update(b"\x00no-config"),
Some(config_path) => {
hasher.update(config_path.display().to_string().as_bytes());
hasher.update(b"\0");
match std::fs::read(&config_path) {
Ok(bytes) => hasher.update(&bytes),
Err(_) => hasher.update(b"\x00unreadable-config"),
}
}
}
hasher.hexdigest()
}
fn validate_root_key(directory: &str) -> String {
let resolved = crate::index_store::py_resolve(directory);
crate::sha256::hexdigest(resolved.display().to_string().as_bytes())
}
pub fn validate_directory_incremental(
directory: &str,
recursive: bool,
verify: bool,
) -> DirectoryValidation {
validate_directory_incremental_in(directory, recursive, verify, None)
}
pub fn validate_directory_incremental_in(
directory: &str,
recursive: bool,
verify: bool,
cache_dir: Option<&Path>,
) -> DirectoryValidation {
use crate::index_store::{
open_validation_store, write_validation_store, FileState, ValidationCacheRow,
};
let timing = std::env::var_os("DECIDED_TIMING").is_some();
let cache_dir = cache_dir
.map(Path::to_path_buf)
.unwrap_or_else(crate::derived_cache::default_cache_dir);
let root_key = validate_root_key(directory);
let config_hash = config_fingerprint(directory);
let prev_rows =
open_validation_store(&cache_dir, &root_key, &config_hash).unwrap_or_default();
let prev_manifest: Vec<(String, FileState)> = prev_rows
.iter()
.map(|(rel, row)| {
(
rel.clone(),
FileState {
content_hash: row.content_hash.clone(),
size: row.size,
mtime_ns: row.mtime_ns,
},
)
})
.collect();
let prev_by_rel: std::collections::HashMap<&str, &ValidationCacheRow> = prev_rows
.iter()
.map(|(rel, row)| (rel.as_str(), row))
.collect();
let detect_start = std::time::Instant::now();
let (new_manifest, changed) =
crate::derived_cache::stat_scan(directory, &prev_manifest, verify, recursive);
let detect_ms = detect_start.elapsed().as_secs_f64() * 1000.0;
let overrides = load_overrides(directory);
let provider = load_ticketing_provider(directory);
let root_display = normalize_root(directory);
let recompute_start = std::time::Instant::now();
let mut new_rows: Vec<(String, ValidationCacheRow)> =
Vec::with_capacity(new_manifest.len());
for (rel, state) in &new_manifest {
if !changed.contains(rel) {
if let Some(prev) = prev_by_rel.get(rel.as_str()) {
new_rows.push((
rel.clone(),
ValidationCacheRow {
size: state.size,
mtime_ns: state.mtime_ns,
content_hash: state.content_hash.clone(),
artifact_type: prev.artifact_type.clone(),
status: prev.status.clone(),
issues: prev.issues.clone(),
},
));
continue;
}
}
let path = format!("{root_display}/{rel}");
let artifact = parse_file(&path);
let spec = crate::spec::spec_for(&crate::classify::classify(&artifact).artifact_type);
let artifact_type = spec
.map(|s| s.name.clone())
.unwrap_or_else(|| "unknown".to_string());
let (status, issues) = if spec.is_none() {
(STATUS_SKIPPED.to_string(), Vec::new())
} else {
let computed = apply_overrides(
validate(&artifact, provider.as_deref(), Some(&artifact_type)),
&artifact_type,
&overrides,
);
let status = if has_errors(&computed) {
STATUS_INVALID
} else {
STATUS_VALID
};
(
status.to_string(),
computed
.into_iter()
.map(|issue| crate::index_store::CachedIssue {
severity: issue.severity.to_string(),
code: issue.code.clone(),
message: issue.message.clone(),
line: issue.line.map(|l| l as u32),
})
.collect(),
)
};
new_rows.push((
rel.clone(),
ValidationCacheRow {
size: state.size,
mtime_ns: state.mtime_ns,
content_hash: state.content_hash.clone(),
artifact_type,
status,
issues,
},
));
}
let recompute_ms = recompute_start.elapsed().as_secs_f64() * 1000.0;
let rows_by_rel: std::collections::HashMap<&str, &ValidationCacheRow> = new_rows
.iter()
.map(|(rel, row)| (rel.as_str(), row))
.collect();
let mut files: Vec<FileValidation> = Vec::new();
let mut okf_entries_owned: Vec<(String, String, String)> = Vec::new();
for entry in crate::walk::find_markdown_files(directory, recursive) {
let rel = entry.components.join("/");
let Some(row) = rows_by_rel.get(rel.as_str()) else {
continue; };
let status: &'static str = match row.status.as_str() {
"valid" => STATUS_VALID,
"invalid" => STATUS_INVALID,
_ => STATUS_SKIPPED,
};
files.push(FileValidation {
path: entry.display.clone(),
artifact_type: row.artifact_type.clone(),
status,
issues: row
.issues
.iter()
.map(|i| Issue {
severity: match i.severity.as_str() {
"error" => "error",
"warning" => "warning",
_ => "info",
},
code: i.code.clone(),
message: i.message.clone(),
line: i.line.map(i64::from),
})
.collect(),
});
let file_name = entry
.display
.rsplit('/')
.next()
.unwrap_or(&entry.display)
.to_string();
okf_entries_owned.push((entry.display.clone(), row.artifact_type.clone(), file_name));
}
let okf_entries: Vec<OkfEntry> = okf_entries_owned
.iter()
.map(|(path, artifact_type, file_name)| OkfEntry {
path,
artifact_type,
file_name,
})
.collect();
let okf = check_okf_conformance(&okf_entries, &overrides);
write_validation_store(&cache_dir, &root_key, &config_hash, &new_rows);
if timing {
eprintln!(
"decided-timing: detect_ms={detect_ms:.3} recompute_ms={recompute_ms:.3} files_changed={}",
changed.len()
);
}
DirectoryValidation {
directory: directory.to_string(),
recursive,
files,
okf: Some(okf),
}
}
pub fn validate_stdin_against_corpus(
artifact: &Artifact,
corpus_dir: &str,
source_path: &str,
recursive: bool,
) -> StdinCorpusValidation {
let structural = validate_product(artifact, corpus_dir);
let relationships =
validate_document_against_corpus(artifact, source_path, corpus_dir, recursive);
StdinCorpusValidation {
source_path: source_path.to_string(),
structural_issues: structural,
relationship_issues: relationships.issues,
}
}
pub struct ValidateArgs {
pub file: String,
pub json: bool,
pub sarif: bool,
pub top_level: bool,
pub corpus: Option<String>,
pub cache: bool,
pub verify: bool,
}
fn py_path_str(p: &str) -> String {
normalize_root(p)
}
fn py_path_parent(p: &str) -> String {
let normalized = py_path_str(p);
if normalized == "/" || normalized == "." {
return normalized;
}
match normalized.rfind('/') {
Some(0) => "/".to_string(),
Some(i) => normalized[..i].to_string(),
None => ".".to_string(),
}
}
fn read_named_file(path: &str) -> Result<Artifact, i32> {
if !Path::new(path).is_file() {
return Err(usage_error(&format!("file not found: {path}")));
}
let artifact = parse_file(path);
if artifact
.parse_issues
.iter()
.any(|i| i.code == "unreadable-artifact")
{
return Err(usage_error(&format!("cannot read {path}")));
}
Ok(artifact)
}
fn read_validate_input(target: &str) -> Result<Artifact, i32> {
if target == "-" {
use std::io::Read;
let mut buf = Vec::new();
let _ = std::io::stdin().lock().read_to_end(&mut buf);
let text = crate::pycompat::decode_stdin_surrogateescape(&buf);
return Ok(parse_text(&text, "-"));
}
read_named_file(target)
}
pub fn cmd_validate(args: &ValidateArgs) -> i32 {
if args.file != "-" && Path::new(&args.file).is_dir() {
if args.corpus.is_some() {
return usage_error("--corpus applies to stdin ('-') or a single file");
}
let result = if crate::derived_cache::cache_enabled(args.cache) {
validate_directory_incremental(&args.file, !args.top_level, args.verify)
} else {
validate_directory(&args.file, !args.top_level)
};
if args.sarif {
emit(output::render_validate_sarif(&result));
} else if args.json {
emit(output::render_validate_dir_json(&result));
} else {
emit(output::render_validate_dir_human(&result));
}
return if result.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
};
}
if args.sarif {
return usage_error("--sarif applies to directory validation");
}
let artifact = match read_validate_input(&args.file) {
Ok(a) => a,
Err(code) => return code,
};
if let Some(corpus) = &args.corpus {
if !Path::new(corpus).is_dir() {
return usage_error(&format!("--corpus is not a directory: {corpus}"));
}
let source_path = if args.file == "-" {
"-".to_string()
} else {
py_path_str(&args.file)
};
let result = validate_stdin_against_corpus(&artifact, corpus, &source_path, true);
if args.json {
emit(output::render_stdin_corpus_json(&result));
} else {
emit(output::render_stdin_corpus_human(&result));
}
return if result.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
};
}
let start = if args.file == "-" {
".".to_string()
} else {
py_path_parent(&args.file)
};
let issues = validate_product(&artifact, &start);
if args.json {
emit(output::render_validation_json(
&artifact.product.source_path,
&issues,
));
} else {
emit(output::render_validation_human(
&artifact.product.source_path,
&issues,
));
}
if has_errors(&issues) {
EXIT_VALIDATION_FAILED
} else {
EXIT_OK
}
}
pub struct DiffArgs {
pub old: String,
pub new: String,
pub json: bool,
}
pub fn cmd_diff(args: &DiffArgs) -> i32 {
let old = match read_named_file(&args.old) {
Ok(a) => a,
Err(code) => return code,
};
let new = match read_named_file(&args.new) {
Ok(a) => a,
Err(code) => return code,
};
let result = crate::diff::diff(&old, &new);
if args.json {
emit(output::render_diff_json(&result, &args.old, &args.new));
} else {
emit(output::render_diff_human(&result));
}
EXIT_OK
}
fn py_suffix_lower(target: &str) -> String {
let name = target.rsplit('/').next().unwrap_or(target);
match name.rfind('.') {
Some(i) if i > 0 && i < name.len() - 1 => name[i..].to_lowercase(),
_ => String::new(),
}
}
fn read_markdown_input(target: &str, command: &str) -> Result<String, i32> {
if target == "-" {
use std::io::Read;
let mut buf = Vec::new();
let _ = std::io::stdin().lock().read_to_end(&mut buf);
return Ok(crate::pycompat::decode_stdin_surrogateescape(&buf));
}
if !Path::new(target).is_file() {
return Err(usage_error(&format!("file not found: {target}")));
}
let suffix = py_suffix_lower(target);
if suffix != ".md" && suffix != ".markdown" {
return Err(usage_error(&format!(
"{command} expects a Markdown file; convert it first with: decided ingest {target}"
)));
}
match std::fs::read(target) {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => Ok(text),
Err(e) => {
eprintln!(
"UnicodeDecodeError: 'utf-8' codec can't decode input: {e}"
);
Err(EXIT_VALIDATION_FAILED)
}
},
Err(e) => Err(usage_error(&format!("cannot read {target}: {e}"))),
}
}
pub struct InspectArgs {
pub file: String,
pub verbose: bool,
pub top_level: bool,
pub json: bool,
}
pub fn cmd_inspect(args: &InspectArgs) -> i32 {
if args.file != "-" && Path::new(&args.file).is_dir() {
let result = crate::inspect::inspect_directory(&args.file, !args.top_level);
if args.json {
emit(output::render_dir_inspect_json(&result));
} else {
emit(output::render_dir_inspect_human(&result));
}
return EXIT_OK;
}
let text = match read_markdown_input(&args.file, "inspect") {
Ok(t) => t,
Err(code) => return code,
};
let artifact = parse_text(&text, "");
let inspection = crate::inspect::build_inspection(&artifact);
if args.verbose && !args.json {
emit(output::render_inspect_verbose(
&inspection,
&crate::classify::score_artifacts(&artifact),
));
} else if args.json {
emit(output::render_inspect_json(&inspection));
} else {
emit(output::render_inspect_human(&inspection));
}
EXIT_OK
}
pub struct ImproveArgs {
pub file: String,
pub json: bool,
pub template: bool,
}
pub fn cmd_improve(args: &ImproveArgs) -> i32 {
let text = match read_markdown_input(&args.file, "improve") {
Ok(t) => t,
Err(code) => return code,
};
let result = crate::improve::improve_product(&parse_text(&text, ""));
if args.json {
emit(output::render_improve_json(&result));
} else if args.template {
emit(output::render_improve_template(&result));
} else {
emit(output::render_improve_human(&result));
}
EXIT_OK
}
pub struct RelationshipsArgs {
pub path: String,
pub validate: bool,
pub sarif: bool,
pub json: bool,
pub top_level: bool,
}
pub fn cmd_relationships(args: &RelationshipsArgs) -> i32 {
if args.sarif && !args.validate {
return usage_error("relationships --sarif requires --validate");
}
let path = Path::new(&args.path);
let is_dir = if path.is_dir() {
true
} else if path.is_file() {
let suffix = args
.path
.rsplit('/')
.next()
.and_then(|name| name.rfind('.').map(|i| name[i..].to_lowercase()))
.unwrap_or_default();
if suffix != ".md" && suffix != ".markdown" {
return usage_error(&format!(
"relationships expects a Markdown file or directory; \
convert it first with: decided ingest {}",
args.path
));
}
false
} else {
return usage_error(&format!("path not found: {}", args.path));
};
if args.validate {
let report = if is_dir {
validate_relationships(&args.path, !args.top_level)
} else {
validate_relationships_file(&args.path)
};
if args.sarif {
emit(output::render_relationships_sarif(&report));
} else if args.json {
emit(output::render_relationship_validation_json(&report));
} else {
emit(output::render_relationship_validation_human(&report));
}
return if report.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
};
}
let report = if is_dir {
build_relationship_report(&args.path, !args.top_level)
} else {
build_relationship_report_file(&args.path)
};
if args.json {
emit(output::render_relationships_json(&report));
} else {
emit(output::render_relationships_human(&report));
}
EXIT_OK
}
pub struct StatsArgs {
pub directory: String,
pub json: bool,
}
pub fn cmd_stats(args: &StatsArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let stats = crate::stats::collect_stats(&args.directory);
if args.json {
emit(output::render_stats_json(&stats));
} else {
emit(output::render_stats_human(&stats));
}
if stats.has_meaningful_content() || stats.is_empty() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct PortfolioArgs {
pub directory: String,
pub json: bool,
pub top_level: bool,
}
pub fn cmd_portfolio(args: &PortfolioArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let recursive = !args.top_level;
let items = corpus_items(&args.directory, recursive);
let summary = crate::portfolio::portfolio_from_corpus(&args.directory, &items, recursive);
if args.json {
emit(output::render_portfolio_json(&summary));
} else {
emit(output::render_portfolio_human(&summary));
}
EXIT_OK
}
pub struct IndexArgs {
pub directory: String,
pub json: bool,
pub top_level: bool,
}
pub fn cmd_index(args: &IndexArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let index = crate::index::build_repository_index(&args.directory, !args.top_level);
if args.json {
emit(output::render_index_json(&index));
} else {
emit(output::render_index_human(&index));
}
EXIT_OK
}
pub struct CoverageArgs {
pub directory: String,
pub json: bool,
}
pub fn cmd_coverage(args: &CoverageArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let report = crate::coverage::analyze_coverage(&args.directory);
if args.json {
emit(output::render_coverage_json(&report));
} else {
emit(output::render_coverage_human(&report));
}
EXIT_OK
}
pub struct DecisionsForArgs {
pub path: String,
pub directory: String,
pub json: bool,
pub top_level: bool,
}
pub fn cmd_decisions_for(args: &DecisionsForArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let result = crate::retrieve::decisions_for_path(&args.directory, &args.path, !args.top_level);
if args.json {
emit(output::render_decisions_for_json(&result));
} else {
emit(output::render_decisions_for_human(&result));
}
EXIT_OK
}
pub struct GateArgs {
pub directory: String,
pub json: bool,
pub sarif: bool,
pub top_level: bool,
pub code: bool,
pub repository: String,
pub base: Option<String>,
pub full: bool,
}
pub fn cmd_gate(args: &GateArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
if args.code && !args.full && args.base.is_none() {
return usage_error("a diff base is required for --code unless --full is supplied");
}
let report = match crate::gate::build_gate_with_code(
&args.directory,
!args.top_level,
args.code.then_some(crate::gate::CodeGateOptions {
repository: &args.repository,
base: args.base.as_deref(),
full_tree: args.full,
}),
) {
Ok(report) => report,
Err(exc) => {
eprintln!("decided: {}", exc.message());
return EXIT_VALIDATION_FAILED;
}
};
if args.sarif {
emit(output::render_gate_sarif(&report));
} else if args.json {
emit(output::render_gate_json(&report));
} else {
emit(output::render_gate_human(&report));
}
if report.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct SentryArgs {
pub directory: String,
pub repository: String,
pub base: Option<String>,
pub full: bool,
pub json: bool,
pub sarif: bool,
pub top_level: bool,
}
pub fn cmd_sentry(args: &SentryArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let report = match crate::sentry::analyze(
&args.directory,
&args.repository,
!args.top_level,
args.base.as_deref(),
args.full,
) {
Ok(report) => report,
Err(message) => return usage_error(&message),
};
if args.sarif {
emit(output::render_sentry_sarif(&report));
} else if args.json {
emit(output::render_sentry_json(&report));
} else {
emit(output::render_sentry_human(&report));
}
if report.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct HeraldArgs {
pub directory: String,
pub paths_file: String,
pub link_base: String,
pub max_inline: i64,
pub out: String,
pub github_output: Option<String>,
pub top_level: bool,
}
pub fn cmd_herald(args: &HeraldArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let paths = match std::fs::read_to_string(&args.paths_file) {
Ok(text) => text.lines().map(str::trim).filter(|line| !line.is_empty()).map(str::to_string).collect::<Vec<_>>(),
Err(error) => return usage_error(&format!("could not read paths file {}: {error}", args.paths_file)),
};
let report = crate::herald::collect(&args.directory, &paths, !args.top_level);
let body = crate::herald::render(&report, &args.link_base, args.max_inline);
if let Err(error) = std::fs::write(&args.out, body) {
return usage_error(&format!("could not write Herald output {}: {error}", args.out));
}
let has_decisions = if report.has_decisions() { "true" } else { "false" };
if let Some(path) = &args.github_output {
use std::io::Write;
let result = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.and_then(|mut file| writeln!(file, "has_decisions={has_decisions}"));
if let Err(error) = result {
return usage_error(&format!("could not write command output {path}: {error}"));
}
}
emit(format!(
"{} governing decision(s); has_decisions={has_decisions}",
report.decisions.len()
));
EXIT_OK
}
pub struct WatchkeeperArgs {
pub directory: Option<String>,
pub base: String,
pub head: Option<String>,
pub format: String, pub json: bool, pub fail_on: String, pub annotate: bool, }
pub fn cmd_watchkeeper(args: &WatchkeeperArgs) -> i32 {
let directory = match &args.directory {
Some(d) => d.clone(),
None => {
if Path::new("decisions").is_dir() {
"decisions".to_string()
} else {
".".to_string()
}
}
};
if !Path::new(&directory).is_dir() {
return usage_error(&format!("not a directory: {directory}"));
}
let report = match crate::watchkeeper::build_watchkeeper_report(
&directory,
&args.base,
args.head.as_deref(),
) {
Ok(report) => report,
Err(exc) => return usage_error(exc.message()),
};
let output_format = if args.json { "json" } else { args.format.as_str() };
if output_format == "json" {
emit(output::render_watchkeeper_json(&report));
} else if output_format == "github" {
emit(output::render_watchkeeper_github(&report));
if args.annotate {
for line in output::watchkeeper_annotations(&report) {
eprintln!("{line}");
}
}
} else {
emit(output::render_watchkeeper_human(&report));
}
if args.fail_on == "none" {
return EXIT_OK;
}
if report.review_recommended() {
return EXIT_VALIDATION_FAILED;
}
if args.fail_on == "warning" && report.has_warnings() {
return EXIT_VALIDATION_FAILED;
}
EXIT_OK
}
pub struct DoctorArgs {
pub directory: String,
pub json: bool,
pub top_level: bool,
pub hub_threshold: i64,
}
pub fn cmd_doctor(args: &DoctorArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let report =
crate::doctor::diagnose(&args.directory, !args.top_level, args.hub_threshold);
if args.json {
emit(output::render_doctor_json(&report));
} else {
emit(output::render_doctor_human(&report));
}
if report.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct ReviewArgs {
pub directory: String,
pub json: bool,
pub sarif: bool,
pub top_level: bool,
pub stale_after: Option<i64>,
}
pub fn cmd_review(args: &ReviewArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
if let Some(days) = args.stale_after {
if days < 0 {
return usage_error("--stale-after must be a non-negative number of days");
}
}
let report = crate::review::build_review(&args.directory, !args.top_level, args.stale_after);
if args.sarif {
emit(output::render_review_sarif(&report));
} else if args.json {
emit(output::render_review_json(&report));
} else {
emit(output::render_review_human(&report));
}
if report.ok() {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct ExportArgs {
pub directory: String,
pub json: bool,
pub graph: bool,
pub documents: bool,
pub html: bool,
pub okf: bool,
pub agent_rules: bool,
pub check: bool,
pub client: Vec<String>,
pub out: Option<String>,
}
pub fn cmd_export(args: &ExportArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
if args.agent_rules {
return cmd_agent_rules(args);
}
if args.check {
return usage_error("--check requires --agent-rules");
}
if !args.client.is_empty() {
return usage_error("--client requires --agent-rules");
}
if args.json && (args.html || args.okf) {
return usage_error("--json cannot combine with --html or --okf");
}
if args.out.is_some() && !(args.html || args.okf) {
return usage_error("--out requires --html or --okf (--json writes to stdout)");
}
if args.documents {
emit(output::render_documents_jsonl(
&crate::export::build_documents_export(&args.directory),
));
return EXIT_OK;
}
if args.graph {
emit(output::render_graph_json(&crate::export::build_graph_export(
&args.directory,
)));
return EXIT_OK;
}
if args.okf {
let export = crate::export::build_okf_export(&args.directory, output::rac_version());
let recency = crate::okf::artifact_recency(&args.directory, &export);
let bundle = match crate::okf::render_okf_bundle(&export, &recency, &args.directory) {
Ok(bundle) => bundle,
Err(msg) => {
eprintln!("ValueError: {msg}");
return EXIT_VALIDATION_FAILED;
}
};
let out = args.out.as_deref().unwrap_or("okf-bundle");
for (rel, content) in &bundle {
let dest = std::path::Path::new(out).join(rel);
let written = dest
.parent()
.map(std::fs::create_dir_all)
.unwrap_or(Ok(()))
.and_then(|_| std::fs::write(&dest, content));
if let Err(exc) = written {
return usage_error(&format!("cannot write {out}: {exc}"));
}
}
let edges = export.relationships.len();
emit(format!(
"wrote {out}/ \u{2014} {} artifact(s), {edges} relationship(s)",
export.artifact_count()
));
return EXIT_OK;
}
let export = crate::export::build_corpus_export(&args.directory, output::rac_version());
if !args.html {
emit(output::render_export_json(&export));
return EXIT_OK;
}
let html = match crate::portal::render_export_html(&export) {
Ok(html) => html,
Err(msg) => return usage_error(&msg), };
let out = args.out.as_deref().unwrap_or("lore-export.html");
if let Err(exc) = std::fs::write(out, html) {
return usage_error(&format!("cannot write {out}: {exc}"));
}
let edges = export.relationships.len();
emit(format!(
"wrote {out} \u{2014} {} artifact(s), {edges} relationship(s)",
export.artifact_count()
));
EXIT_OK
}
fn cmd_agent_rules(args: &ExportArgs) -> i32 {
let root = crate::agent_rules::agent_rules_root(&args.directory, args.out.as_deref());
let result = if args.check {
crate::agent_rules::check_agent_rules(&args.directory, &root, &args.client)
} else {
match crate::agent_rules::generate_agent_rules(&args.directory, &root, &args.client) {
Ok(result) => result,
Err(exc) => return usage_error(&format!("cannot write under {root}: {exc}")),
}
};
if args.json {
emit(output::render_agent_rules_json(&result));
} else {
emit(output::render_agent_rules_human(&result));
}
if args.check && result.drifted() {
return EXIT_VALIDATION_FAILED;
}
EXIT_OK
}
pub struct SchemaArgs {
pub schema: Option<String>,
pub list: bool,
pub json: bool,
pub template: bool,
}
pub fn cmd_schema(args: &SchemaArgs) -> i32 {
let names = crate::spec::available_schemas();
if args.list {
if args.template {
return usage_error("--template cannot be used with --list");
}
if args.schema.is_some() {
return usage_error("schema name cannot be used with --list");
}
if args.json {
emit(output::render_schema_list_json(&names));
} else {
emit(output::render_schema_list_human(&names));
}
return EXIT_OK;
}
let Some(name) = &args.schema else {
return usage_error("schema name required unless --list is passed");
};
let Some(spec) = crate::spec::spec_for(name) else {
eprintln!("{}", output::render_unknown_schema(name, &names));
return EXIT_USAGE;
};
if args.json {
emit(output::render_schema_json(spec));
} else if args.template {
emit(output::render_schema_template(spec));
} else {
emit(output::render_schema_human(spec));
}
EXIT_OK
}
pub struct TemplatesArgs {
pub json: bool,
}
pub fn cmd_templates(args: &TemplatesArgs) -> i32 {
let names = crate::spec::available_schemas();
if args.json {
emit(output::render_templates_json(&names));
} else {
emit(output::render_templates_human(&names));
}
EXIT_OK
}
pub struct ResolveArgs {
pub id: String,
pub directory: String,
pub json: bool,
pub top_level: bool,
}
pub fn cmd_resolve(args: &ResolveArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let result = crate::resolve::resolve_artifact(&args.directory, &args.id, !args.top_level);
if args.json {
emit(output::render_resolve_json(&result));
} else if result.outcome == crate::resolve::OUTCOME_RESOLVED {
emit(output::render_resolve_human(
result.artifact.as_ref().expect("resolved implies artifact"),
));
} else if result.outcome == crate::resolve::OUTCOME_DUPLICATE {
let found: Vec<String> = result
.duplicate_paths
.iter()
.map(|p| format!("- {p}"))
.collect();
eprintln!(
"decided: duplicate artifact ID: {}\n\nFound in:\n{}",
args.id,
found.join("\n")
);
} else {
eprintln!("decided: artifact not found: {}", args.id);
}
if result.outcome == crate::resolve::OUTCOME_RESOLVED {
EXIT_OK
} else {
EXIT_VALIDATION_FAILED
}
}
pub struct FindArgs {
pub query: String,
pub directory: String,
pub artifact_type: Option<String>,
pub decisions: bool,
pub tags: Vec<String>,
pub json: bool,
pub explain: bool,
pub top_level: bool,
pub live: bool,
pub cache: bool,
pub verify: bool,
}
pub fn annotate_search_recency(matches: &mut [crate::resolve::ResolvedArtifact], directory: &str) {
use crate::gitinfo;
if matches.is_empty() {
return;
}
let timing_started = crate::timing::start();
let threshold = crate::validate::load_freshness_threshold(directory);
let reference = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let repo_root = gitinfo::repository_root(Path::new(directory));
let paths: Vec<PathBuf> = matches.iter().map(|m| PathBuf::from(&m.path)).collect();
let committed = match &repo_root {
Some(root) => gitinfo::last_committed_for_paths_in_repo(root, &paths),
None => paths.into_iter().map(|path| (path, None)).collect(),
};
for (m, (_, last)) in matches.iter_mut().zip(committed) {
let st = gitinfo::staleness(last.as_deref(), threshold, reference);
m.recency = Some(crate::resolve::Recency {
last_committed: st
.last_committed
.as_deref()
.map(gitinfo::isoformat_roundtrip),
age_days: st.age_days,
stale: st.stale,
});
}
crate::timing::emit_since(
"git.recency_join",
timing_started,
&[
("matches", matches.len() as u64),
("repository", u64::from(repo_root.is_some())),
],
);
}
fn find_from_store(args: &FindArgs) -> crate::resolve::SearchResult {
use crate::derived_cache::{DerivedIndexCache, ReadModel};
let view = DerivedIndexCache::default().load_or_build(
&args.directory,
!args.top_level,
args.verify,
);
match view {
ReadModel::View(reader) => {
if args.decisions {
crate::read_model::store_find_decisions(&reader, &args.query)
} else {
crate::read_model::store_search(
&reader,
&args.query,
args.artifact_type.as_deref(),
&args.tags,
args.live,
)
}
}
ReadModel::Fresh(derived) => {
if args.decisions {
crate::read_model::find_decisions_in(
&derived.index_entries,
&derived.live_decision_paths,
&args.query,
)
} else {
crate::resolve::search_index_filtered(
&derived.index_entries,
&args.query,
args.artifact_type.as_deref(),
&args.tags,
args.live,
)
}
}
}
}
pub fn cmd_find(args: &FindArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let mut result = if crate::derived_cache::cache_enabled(args.cache) {
find_from_store(args)
} else if args.decisions {
crate::resolve::find_decisions(&args.directory, &args.query, !args.top_level)
} else {
crate::resolve::find_artifacts(
&args.directory,
&args.query,
args.artifact_type.as_deref(),
!args.top_level,
&args.tags,
args.live,
)
};
annotate_search_recency(&mut result.matches, &args.directory);
let render_started = crate::timing::start();
let rendered = if args.json {
output::render_find_json(&result, args.explain)
} else {
output::render_find_human(&result, args.explain)
};
crate::timing::emit_since(
"cli.response_serialize",
render_started,
&[("matches", result.matches.len() as u64), ("bytes", rendered.len() as u64)],
);
emit(rendered);
EXIT_OK
}
pub struct RetrieveArgs {
pub task: String,
pub directory: String,
pub scope: Option<String>,
pub top_k: i64,
pub budget: i64,
pub all: bool,
pub json: bool,
}
pub fn cmd_retrieve(args: &RetrieveArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
if args.top_k < 1 {
return usage_error(&format!("--top-k must be at least 1, got {}", args.top_k));
}
if args.budget < 1 {
return usage_error(&format!("--budget must be at least 1, got {}", args.budget));
}
let payload = crate::retrieve::retrieve_grounding(
&args.directory,
&args.task,
args.scope.as_deref(),
args.top_k,
args.budget,
!args.all,
);
let serialized = crate::budget::serialize(&payload, args.budget);
if args.json {
emit(serialized);
} else {
let truncated: serde_json::Value =
serde_json::from_str(&serialized).expect("serialized payload is valid JSON");
emit(output::render_retrieve_human(&truncated));
}
EXIT_OK
}
fn state_log_crash() -> i32 {
eprintln!("decided-rs: state log is not valid UTF-8");
EXIT_VALIDATION_FAILED
}
pub struct McpStatsArgs {
pub json: bool,
pub share: bool,
}
pub fn cmd_mcp_stats(args: &McpStatsArgs) -> i32 {
let summary = match crate::telemetry::summarize() {
Ok(summary) => summary,
Err(_) => return state_log_crash(),
};
if args.share {
emit(crate::telemetry::share_url(&summary));
} else if args.json {
emit(output::render_mcp_stats_json(&summary));
} else {
emit(output::render_mcp_stats_human(&summary));
}
EXIT_OK
}
pub struct UsageArgs {
pub json: bool,
pub share: bool,
}
pub fn cmd_usage(args: &UsageArgs) -> i32 {
let summary = match crate::usage::summarize_usage() {
Ok(summary) => summary,
Err(_) => return state_log_crash(),
};
let guide = match crate::telemetry::summarize() {
Ok(guide) => guide,
Err(_) => return state_log_crash(),
};
if args.share {
emit(crate::usage::share_url(&summary, &guide));
} else if args.json {
emit(output::render_usage_json(&summary, &guide));
} else {
emit(output::render_usage_human(&summary, &guide));
}
EXIT_OK
}
pub struct SkillArgs {
pub action: String,
pub name: Option<String>,
pub dir: String,
pub json: bool,
}
pub fn cmd_skill(args: &SkillArgs) -> i32 {
use crate::skill::{install_skills, SkillInstallError};
if args.action == "list" {
if args.name.is_some() {
return usage_error("skill list takes no skill name");
}
if args.json {
emit(output::render_skill_list_json());
} else {
emit(output::render_skill_list_human());
}
return EXIT_OK;
}
if !Path::new(&args.dir).is_dir() {
return usage_error(&format!("not a directory: {}", args.dir));
}
let installation = match install_skills(&args.dir, args.name.as_deref()) {
Ok(installation) => installation,
Err(SkillInstallError::NotFound(message)) => return usage_error(&message),
Err(SkillInstallError::FileExists(message)) | Err(SkillInstallError::Io(message)) => {
eprintln!("decided: {message}");
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_skill_install_json(&installation));
} else {
emit(output::render_skill_install_human(&installation));
}
EXIT_OK
}
pub struct HookArgs {
pub action: String,
pub style: String,
pub dir: String,
pub json: bool,
}
pub fn cmd_hook(args: &HookArgs) -> i32 {
use crate::hook::{install_hook, HookInstallError};
if args.action == "list" {
if args.json {
emit(output::render_hook_list_json());
} else {
emit(output::render_hook_list_human());
}
return EXIT_OK;
}
if !Path::new(&args.dir).is_dir() {
return usage_error(&format!("not a directory: {}", args.dir));
}
let installation = match install_hook(&args.dir, &args.style) {
Ok(installation) => installation,
Err(HookInstallError::NotAGitWorkTree(message)) => return usage_error(&message),
Err(HookInstallError::FileExists(message)) | Err(HookInstallError::Io(message)) => {
eprintln!("decided: {message}");
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_hook_install_json(&installation));
} else {
emit(output::render_hook_install_human(&installation));
}
EXIT_OK
}
pub struct EvalArgs {
pub check: bool,
pub update_baseline: bool,
pub json: bool,
pub root: String,
pub queries: String,
pub baseline: String,
pub config: String,
}
pub fn cmd_eval(args: &EvalArgs) -> i32 {
use crate::eval;
let fail = |err: eval::EvalUsageError| -> i32 {
eprintln!("decided eval: {}", err.0);
EXIT_USAGE
};
let scorecard = match eval::run_eval(&args.root, &args.queries) {
Ok(scorecard) => scorecard,
Err(err) => return fail(err),
};
if args.update_baseline {
let payload = eval::render_metrics_json(&scorecard.metrics) + "\n";
if let Err(e) = std::fs::write(&args.baseline, payload) {
eprintln!("decided: cannot write {}: {e}", args.baseline);
return EXIT_VALIDATION_FAILED;
}
emit(format!("decided eval: baseline updated -> {}", args.baseline));
return EXIT_OK;
}
if args.check {
let baseline = match eval::load_baseline(&args.baseline) {
Ok(baseline) => baseline,
Err(err) => return fail(err),
};
let config = match eval::load_config(&args.config) {
Ok(config) => config,
Err(err) => return fail(err),
};
let failures = eval::evaluate_gate(&scorecard.metrics, &baseline, &config);
if !failures.is_empty() {
for failure in &failures {
emit(failure.render());
}
return EXIT_VALIDATION_FAILED;
}
emit("decided eval: gate PASS".to_string());
return EXIT_OK;
}
if args.json {
emit(eval::render_scorecard_json(&scorecard));
} else {
emit(eval::render_scorecard_human(&scorecard));
}
EXIT_OK
}
pub struct NewArgs {
pub artifact_type: String,
pub output_path: String,
pub json: bool,
}
pub fn cmd_new(args: &NewArgs) -> i32 {
use crate::scaffold::ScaffoldError;
let created = match crate::scaffold::create_artifact(&args.artifact_type, &args.output_path) {
Ok(created) => created,
Err(
e @ (ScaffoldError::TemplateNotFound(_)
| ScaffoldError::OutputPathExists(_)
| ScaffoldError::OutputDirectoryMissing(_)
| ScaffoldError::MissingRepositoryConfig(_)),
) => return usage_error(e.message()),
Err(e) => {
eprintln!("decided: {}", e.message());
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_new_json(&created));
} else {
emit(output::render_new_human(&created));
}
EXIT_OK
}
fn maybe_ask_usage_sharing() {
use std::io::{BufRead, IsTerminal, Write};
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal())
|| crate::consent::consent_recorded()
{
return;
}
{
let mut out = std::io::stdout().lock();
let _ = out.write_all("\nShare anonymous usage to help shape AsDecided? [y/N] ".as_bytes());
let _ = out.flush();
}
let mut answer = String::new();
let _ = std::io::stdin().lock().read_line(&mut answer); if let Some(message) = handle_share_answer(&answer) {
emit(message.to_string());
}
}
fn handle_share_answer(answer: &str) -> Option<&'static str> {
if share_answer_is_yes(answer) {
crate::consent::opt_in();
Some(
"Sharing on \u{2014} one anonymous daily ping. 'decided telemetry status' \
shows exactly what; 'decided telemetry off' stops it.",
)
} else {
crate::consent::decline();
None
}
}
fn share_answer_is_yes(answer: &str) -> bool {
matches!(
crate::pycompat::py_strip(answer).to_lowercase().as_str(),
"y" | "yes"
)
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod share_prompt_tests {
use super::share_answer_is_yes;
#[test]
fn share_answer_classification() {
for yes in ["y", "Y", "yes", "YES", " y ", "Yes\n"] {
assert!(share_answer_is_yes(yes), "{yes:?} should opt in");
}
for no in ["", "\n", "n", "no", "yess", "y e s", "ok"] {
assert!(!share_answer_is_yes(no), "{no:?} should decline");
}
}
}
pub struct InitArgs {
pub directory: String,
pub key: String,
pub ticketing: Option<String>,
pub profile: Option<String>,
pub org_endpoint: Option<String>,
pub json: bool,
}
pub fn cmd_init(args: &InitArgs) -> i32 {
use crate::scaffold::ScaffoldError;
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let result = match crate::scaffold::init_repository(
&args.directory,
&args.key,
args.ticketing.as_deref(),
args.profile.as_deref(),
args.org_endpoint.as_deref(),
) {
Ok(result) => result,
Err(
e @ (ScaffoldError::InvalidRepositoryKey(_) | ScaffoldError::InvalidOrgEndpoint(_)),
) => return usage_error(e.message()),
Err(e) => {
eprintln!("decided: {}", e.message());
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_init_json(&result));
} else {
emit(output::render_init_human(&result));
maybe_ask_usage_sharing();
}
EXIT_OK
}
pub struct QuickstartArgs {
pub directory: String,
pub key: String,
pub artifact_type: String,
pub json: bool,
}
pub fn cmd_quickstart(args: &QuickstartArgs) -> i32 {
use crate::scaffold::ScaffoldError;
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let result =
match crate::scaffold::quickstart(&args.directory, &args.key, &args.artifact_type) {
Ok(result) => result,
Err(
e @ (ScaffoldError::TemplateNotFound(_)
| ScaffoldError::InvalidRepositoryKey(_)
| ScaffoldError::OutputDirectoryMissing(_)),
) => return usage_error(e.message()),
Err(e) => {
eprintln!("decided: {}", e.message());
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_quickstart_json(&result));
} else {
emit(output::render_quickstart_human(&result));
maybe_ask_usage_sharing();
}
EXIT_OK
}
pub struct MigrateArgs {
pub target: String,
pub directory: String,
pub dry_run: bool,
pub top_level: bool,
pub json: bool,
}
pub fn cmd_migrate(args: &MigrateArgs) -> i32 {
use crate::scaffold::ScaffoldError;
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
if args.target == "layout" {
return migrate_layout(args);
}
let report = match crate::scaffold::migrate_metadata(
&args.directory,
args.dry_run,
!args.top_level,
) {
Ok(report) => report,
Err(e @ ScaffoldError::MissingRepositoryConfig(_)) => return usage_error(e.message()),
Err(e) => {
eprintln!("decided: {}", e.message());
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_migrate_json(&report));
} else {
emit(output::render_migrate_human(&report));
}
EXIT_OK
}
fn migrate_layout(args: &MigrateArgs) -> i32 {
let root = Path::new(&args.directory);
let moves = [
(root.join(".rac"), root.join(".decided")),
(root.join("rac"), root.join("decisions")),
];
let planned: Vec<_> = moves
.iter()
.filter(|(from, _)| from.exists())
.collect();
for (_, to) in &planned {
if to.exists() {
return usage_error(&format!(
"refusing layout migration because destination already exists: {}",
to.display()
));
}
}
if !args.dry_run {
for (from, to) in &planned {
if let Err(error) = std::fs::rename(from, to) {
eprintln!(
"decided: cannot migrate {} to {}: {error}",
from.display(),
to.display()
);
return EXIT_VALIDATION_FAILED;
}
}
}
if args.json {
let operations: Vec<_> = planned
.iter()
.map(|(from, to)| {
serde_json::json!({"from": from, "to": to})
})
.collect();
emit(
serde_json::to_string_pretty(&serde_json::json!({
"directory": args.directory,
"dry_run": args.dry_run,
"operations": operations,
}))
.expect("layout migration result is serializable"),
);
} else if planned.is_empty() {
emit("No legacy .rac or rac layout found.".to_string());
} else {
let verb = if args.dry_run { "Would move" } else { "Moved" };
for (from, to) in planned {
emit(format!("{verb} {} -> {}", from.display(), to.display()));
}
}
EXIT_OK
}
pub struct RenameArgs {
pub old: String,
pub new: String,
pub directory: String,
pub apply: bool,
pub top_level: bool,
pub json: bool,
}
pub fn cmd_rename(args: &RenameArgs) -> i32 {
if !Path::new(&args.directory).is_dir() {
return usage_error(&format!("not a directory: {}", args.directory));
}
let plan =
crate::rename::compute_rename(&args.directory, &args.old, &args.new, !args.top_level);
if !plan.ok {
if args.json {
emit(output::render_rename_json(&plan));
} else {
eprintln!("{}", output::render_rename_human(&plan));
}
return EXIT_VALIDATION_FAILED;
}
if !args.apply {
if args.json {
emit(output::render_rename_json(&plan));
} else {
emit(output::render_rename_human(&plan));
}
return EXIT_OK;
}
let result = match crate::rename::apply_rename(&plan) {
Ok(result) => result,
Err(message) => {
eprintln!("{message}");
return EXIT_VALIDATION_FAILED;
}
};
if args.json {
emit(output::render_rename_result_json(&result));
} else {
emit(output::render_rename_result_human(&result));
}
EXIT_OK
}
pub struct TelemetryArgs {
pub action: String,
pub enterprise: bool,
pub unlock: bool,
}
pub fn cmd_telemetry(args: &TelemetryArgs) -> i32 {
if (args.enterprise || args.unlock) && args.action != "off" {
return usage_error("--enterprise/--unlock are only valid with 'decided telemetry off'");
}
if args.unlock && !args.enterprise {
return usage_error(
"--unlock requires --enterprise (use 'decided telemetry off --enterprise --unlock')",
);
}
if args.action == "on" {
if crate::consent::load_consent().enterprise_locked {
return usage_error(
"cannot opt in while the enterprise telemetry lock is set; remove it with \
'decided telemetry off --enterprise --unlock' first (ADR-086).",
);
}
let record = crate::consent::opt_in();
emit(format!("Sharing on. Install id: {}", record.install_id));
emit(
"One anonymous daily ping: install id, decided version, active-repo count. \
Never paths, queries, or content (ADR-041)."
.to_string(),
);
#[allow(clippy::const_is_empty)]
if crate::consent::POSTHOG_API_KEY.is_empty() {
emit("Note: this build has no PostHog key configured; nothing will be sent.".to_string());
}
} else if args.action == "off" {
if args.enterprise && args.unlock {
crate::consent::enterprise_unlock();
emit(
"Enterprise lock removed. Sharing stays off; re-enable with \
'decided telemetry on' (ADR-086)."
.to_string(),
);
} else if args.enterprise {
crate::consent::enterprise_lock();
emit(
"Sharing off and enterprise-locked. The daily ping is forced off \
and cannot be re-enabled until unlocked with \
'decided telemetry off --enterprise --unlock' (ADR-086)."
.to_string(),
);
} else {
crate::consent::opt_out();
emit("Sharing off. Nothing will be sent.".to_string());
}
} else {
let status = crate::consent::consent_status();
let sharing = if status.enterprise_locked {
"locked (enterprise)"
} else if status.sharing {
"on"
} else {
"off"
};
emit(format!("Sharing: {sharing}"));
emit(format!(
"Install id: {}",
if status.install_id.is_empty() {
"(none)"
} else {
&status.install_id
}
));
emit(format!(
"Consented at: {}",
if status.consented_at.is_empty() {
"(never)"
} else {
&status.consented_at
}
));
emit(format!("Consent file: {}", status.path));
if status.enterprise_locked {
emit(
"Enterprise lock: on \u{2014} the daily ping is forced off. Remove with \
'decided telemetry off --enterprise --unlock' (ADR-086)."
.to_string(),
);
} else if status.sharing {
emit(
"Shared daily: install id, decided version, active-repo count. \
Never paths, queries, or content (ADR-041)."
.to_string(),
);
}
if !status.endpoint_configured {
emit("Endpoint key: not configured \u{2014} nothing is sent.".to_string());
}
}
EXIT_OK
}