Skip to main content

dotrepo_cli/
commands.rs

1use anyhow::{bail, Context, Result};
2use dotrepo_core::{
3    adopt_managed_surface, adopt_overlay_record, adoption_status_repository,
4    analyze_index_promotion, append_claim_event, apply_index_promotions,
5    build_public_freshness_with_digest, canonical_mirror_path_for_claim_path,
6    current_public_freshness, current_timestamp_rfc3339, export_public_index_static_with_options,
7    generate_check_repository, import_repository_with_options, index_snapshot_digest,
8    inspect_claim_directory, inspect_surface_states, load_manifest_document,
9    load_manifest_from_root, managed_outputs, native_repository_identity, preview_surfaces,
10    public_profile_compare_with_base, public_profile_search_with_base,
11    public_repository_batch_profiles_with_base, public_repository_batch_query_with_base,
12    public_repository_profile_or_error_with_base, public_repository_query_or_error_with_base,
13    public_repository_relations_with_base, public_repository_summary_or_error_with_base,
14    public_repository_trust_or_error_with_base, query_repository, render_dotrepo_ci_workflow,
15    resolve_claim_directory, scaffold_claim_directory, trust_repository,
16    validate_claim_path_matches_native_identity, validate_index_root, validate_manifest,
17    validate_repository, write_import_outputs, AdoptionRepositoryIdentity, ClaimEventAppendInput,
18    ClaimScaffoldInput, DoctorReport, DoctorSurface, ImportOptions, IndexFindingSeverity,
19    PublicErrorResponse, PublicProfileSearchOptions, PublicRepositoryIdentity,
20};
21use dotrepo_schema::scaffold_manifest as render_scaffold_manifest;
22use dotrepo_schema::RecordMode;
23use serde::Serialize;
24use std::fs;
25use std::path::PathBuf;
26
27use crate::cli::{
28    CiCommand, ClaimEventKindArg, CorrectedClaimStateArg, ImportModeArg, PreviewSurfaceArg,
29    PublicCommand,
30};
31
32use crate::error::CliExit;
33use crate::format::{
34    format_claim_report, format_doctor_surface, format_managed_file_state, format_query_value,
35    format_trust_report, print_surface_preview_report,
36};
37
38pub fn cmd_validate(root: PathBuf) -> Result<()> {
39    let report = validate_repository(&root);
40    if !report.valid {
41        return Err(CliExit {
42            code: 1,
43            message: format!(
44                "manifest invalid:\n{}",
45                report
46                    .diagnostics
47                    .into_iter()
48                    .map(|diagnostic| format!("- [{}] {}", diagnostic.source, diagnostic.message))
49                    .collect::<Vec<_>>()
50                    .join("\n")
51            ),
52        }
53        .into());
54    }
55    println!("manifest valid");
56    Ok(())
57}
58
59pub fn cmd_init(root: PathBuf, force: bool) -> Result<()> {
60    let manifest_path = root.join(".repo");
61    if manifest_path.exists() && !force {
62        bail!(
63            "{} already exists; rerun with --force to overwrite it",
64            manifest_path.display()
65        );
66    }
67
68    let repo_name = root
69        .file_name()
70        .and_then(|name| name.to_str())
71        .filter(|name| !name.is_empty())
72        .unwrap_or("repository")
73        .to_string();
74    // When falling back to directory name or the generic default "repository",
75    // the generated .repo will contain a placeholder. Users should edit repo.name
76    // (or pass a more specific root). We deliberately do not fail init for usability.
77    let manifest = render_scaffold_manifest(&repo_name)?;
78    fs::write(&manifest_path, manifest)?;
79    println!("initialized {}", manifest_path.display());
80    Ok(())
81}
82
83pub fn cmd_import(
84    root: PathBuf,
85    mode: ImportModeArg,
86    source: Option<String>,
87    force: bool,
88) -> Result<()> {
89    let plan = import_repository_with_options(
90        &root,
91        mode.into(),
92        source.as_deref(),
93        &ImportOptions {
94            generated_at: Some(current_timestamp()?),
95            github: None,
96        },
97    )?;
98
99    let mut outputs = vec![(plan.manifest_path.clone(), plan.manifest_text.clone())];
100    if let (Some(path), Some(contents)) = (&plan.evidence_path, &plan.evidence_text) {
101        outputs.push((path.clone(), contents.clone()));
102    }
103
104    let written_paths = outputs
105        .iter()
106        .map(|(path, _)| path.clone())
107        .collect::<Vec<_>>();
108    write_import_outputs(outputs, force, "--force")?;
109    for path in written_paths {
110        println!("imported {}", path.display());
111    }
112
113    if !plan.imported_sources.is_empty() {
114        println!("- imported from: {}", plan.imported_sources.join(", "));
115    }
116    if !plan.inferred_fields.is_empty() {
117        println!("- inferred defaults: {}", plan.inferred_fields.join(", "));
118    }
119    println!("- mode: {:?}", plan.manifest.record.mode);
120    println!("- status: {:?}", plan.manifest.record.status);
121
122    Ok(())
123}
124
125pub fn cmd_adopt_overlay(root: PathBuf, overlay_record: PathBuf, force: bool) -> Result<()> {
126    let plan = adopt_overlay_record(&root, &overlay_record)?;
127    let manifest_path = plan.manifest_path.clone();
128    write_import_outputs(
129        vec![(plan.manifest_path, plan.manifest_text)],
130        force,
131        "--force",
132    )?;
133    println!("adopted overlay into {}", manifest_path.display());
134    println!("- imported from: {}", overlay_record.display());
135    println!("- mode: {:?}", plan.manifest.record.mode);
136    println!("- status: {:?}", plan.manifest.record.status);
137    Ok(())
138}
139
140pub fn cmd_query(root: PathBuf, path: &str, json: bool, raw: bool) -> Result<()> {
141    let report = query_repository(&root, path)?;
142    if json {
143        println!("{}", serde_json::to_string_pretty(&report)?);
144    } else {
145        if raw && !report.conflicts.is_empty() {
146            bail!("--raw is only supported when query selection has no competing records");
147        }
148        println!("{}", format_query_value(&report.value, raw)?);
149    }
150    Ok(())
151}
152
153pub fn cmd_validate_index(index_root: PathBuf) -> Result<()> {
154    let findings = validate_index_root(&index_root)?;
155    if findings.is_empty() {
156        println!("index valid");
157        return Ok(());
158    }
159
160    let mut warnings = Vec::new();
161    let mut errors = Vec::new();
162    for finding in findings {
163        match finding.severity {
164            IndexFindingSeverity::Warning => warnings.push(finding),
165            IndexFindingSeverity::Error => errors.push(finding),
166        }
167    }
168
169    if errors.is_empty() {
170        println!("index valid with warnings");
171        for finding in warnings {
172            println!("warning: {}: {}", finding.path.display(), finding.message);
173        }
174        return Ok(());
175    }
176
177    let mut sections = vec![format!(
178        "index validation failed:\n{}",
179        errors
180            .into_iter()
181            .map(|finding| format!("- {}: {}", finding.path.display(), finding.message))
182            .collect::<Vec<_>>()
183            .join("\n")
184    )];
185
186    if !warnings.is_empty() {
187        sections.push(format!(
188            "warnings:\n{}",
189            warnings
190                .into_iter()
191                .map(|finding| format!("- {}: {}", finding.path.display(), finding.message))
192                .collect::<Vec<_>>()
193                .join("\n")
194        ));
195    }
196
197    Err(CliExit {
198        code: 1,
199        message: sections.join("\n"),
200    }
201    .into())
202}
203
204pub fn cmd_promotion_report(
205    index_root: PathBuf,
206    apply: bool,
207    limit: Option<usize>,
208    json: bool,
209    verbose: bool,
210) -> Result<()> {
211    let apply_report = if apply {
212        Some(apply_index_promotions(&index_root, limit)?)
213    } else {
214        None
215    };
216    let report = analyze_index_promotion(&index_root)?;
217
218    if json {
219        #[derive(Serialize)]
220        #[serde(rename_all = "camelCase")]
221        struct JsonReport {
222            total_records: usize,
223            eligible_count: usize,
224            promotion_candidate_count: usize,
225            field_blocker_counts: std::collections::HashMap<String, usize>,
226            applied: Option<JsonApplyReport>,
227            records: Vec<JsonRecord>,
228        }
229        #[derive(Serialize)]
230        #[serde(rename_all = "camelCase")]
231        struct JsonApplyReport {
232            promoted_count: usize,
233            skipped_eligible_count: usize,
234            records: Vec<JsonAppliedRecord>,
235        }
236        #[derive(Serialize)]
237        #[serde(rename_all = "camelCase")]
238        struct JsonAppliedRecord {
239            path: String,
240            previous_status: String,
241            reason: String,
242        }
243        #[derive(Serialize)]
244        #[serde(rename_all = "camelCase")]
245        struct JsonRecord {
246            path: String,
247            source_url: Option<String>,
248            status: Option<String>,
249            eligible: bool,
250            blockers: Vec<String>,
251        }
252        let json_report = JsonReport {
253            total_records: report.summary.total_records,
254            eligible_count: report.summary.eligible_count,
255            promotion_candidate_count: report.summary.promotion_candidate_count,
256            field_blocker_counts: report.summary.field_blocker_counts,
257            applied: apply_report.as_ref().map(|applied| JsonApplyReport {
258                promoted_count: applied.promoted_records.len(),
259                skipped_eligible_count: applied.skipped_eligible_count,
260                records: applied
261                    .promoted_records
262                    .iter()
263                    .map(|record| JsonAppliedRecord {
264                        path: record.path.clone(),
265                        previous_status: record.previous_status.clone(),
266                        reason: record.reason.clone(),
267                    })
268                    .collect(),
269            }),
270            records: report
271                .records
272                .into_iter()
273                .map(|r| {
274                    let blockers: Vec<String> = r
275                        .scores
276                        .iter()
277                        .filter(|s| {
278                            !matches!(
279                                s.confidence,
280                                dotrepo_core::FieldConfidence::HighConfidencePresent
281                                    | dotrepo_core::FieldConfidence::HighConfidenceAbsent
282                            )
283                        })
284                        .map(|s| {
285                            format!(
286                                "{}: {:?} — {}",
287                                s.field,
288                                match s.confidence {
289                                    dotrepo_core::FieldConfidence::MediumConfidencePresent =>
290                                        "medium-present",
291                                    dotrepo_core::FieldConfidence::Unresolved => "unresolved",
292                                    _ => "other",
293                                },
294                                s.reason
295                            )
296                        })
297                        .collect();
298                    JsonRecord {
299                        path: r.path,
300                        source_url: r.source_url,
301                        status: r.status,
302                        eligible: r.eligible,
303                        blockers,
304                    }
305                })
306                .collect(),
307        };
308        println!("{}", serde_json::to_string_pretty(&json_report)?);
309        return Ok(());
310    }
311
312    let s = &report.summary;
313    println!(
314        "promotion analysis: {}/{} records eligible for verified auto-publish; {} can increase high-signal count",
315        s.eligible_count, s.total_records, s.promotion_candidate_count
316    );
317    println!();
318
319    if !s.field_blocker_counts.is_empty() {
320        let mut blockers: Vec<_> = s.field_blocker_counts.iter().collect();
321        blockers.sort_by(|a, b| b.1.cmp(a.1));
322        println!("field blockers (blocking auto-publish):");
323        for (field, count) in &blockers {
324            println!("  {} × {}", count, field);
325        }
326        println!();
327    }
328
329    if let Some(applied) = apply_report {
330        println!(
331            "applied promotions: {} promoted, {} eligible records skipped by limit",
332            applied.promoted_records.len(),
333            applied.skipped_eligible_count
334        );
335        for record in applied.promoted_records {
336            println!(
337                "  promoted {} from {} ({})",
338                record.path, record.previous_status, record.reason
339            );
340        }
341        println!();
342    }
343
344    if verbose {
345        for record in &report.records {
346            let tag = if record.eligible {
347                "ELIGIBLE"
348            } else {
349                "BLOCKED"
350            };
351            println!(
352                "[{}] {} ({})",
353                tag,
354                record.path,
355                record.source_url.as_deref().unwrap_or("?")
356            );
357            for score in &record.scores {
358                let marker = match score.confidence {
359                    dotrepo_core::FieldConfidence::HighConfidencePresent => "H+",
360                    dotrepo_core::FieldConfidence::MediumConfidencePresent => "M+",
361                    dotrepo_core::FieldConfidence::HighConfidenceAbsent => "H-",
362                    dotrepo_core::FieldConfidence::Unresolved => "??",
363                };
364                println!("  {} {}: {}", marker, score.field, score.reason);
365            }
366            println!();
367        }
368    }
369
370    Ok(())
371}
372
373pub fn cmd_generate(root: PathBuf, check: bool) -> Result<()> {
374    if check {
375        let manifest = load_manifest_from_root(&root)?;
376        validate_manifest(&root, &manifest)?;
377        ensure_native_record_command(&manifest.record.mode, "generate-check")?;
378        let report = generate_check_repository(&root)?;
379        if !report.stale.is_empty() {
380            return Err(CliExit {
381                code: 2,
382                message: format!(
383                    "generated files are out of date:\n{}",
384                    report
385                        .outputs
386                        .into_iter()
387                        .filter(|output| output.stale)
388                        .map(|output| {
389                            let mut line = format!(
390                                "- {} [{}]",
391                                output.path,
392                                format_managed_file_state(output.state)
393                            );
394                            if let Some(message) = output.message {
395                                line.push_str(&format!(": {}", message));
396                            }
397                            line
398                        })
399                        .collect::<Vec<_>>()
400                        .join("\n")
401                ),
402            }
403            .into());
404        }
405        return Ok(());
406    }
407
408    let document = load_manifest_document(&root)?;
409    validate_manifest(&root, &document.manifest)?;
410    ensure_native_record_command(&document.manifest.record.mode, "generate")?;
411    let outputs = managed_outputs(&root, &document.manifest, &document.raw)?;
412
413    for (path, contents) in outputs {
414        if let Some(parent) = path.parent() {
415            fs::create_dir_all(parent)?;
416        }
417        fs::write(&path, contents)?;
418        println!("generated {}", path.display());
419    }
420
421    Ok(())
422}
423
424pub fn cmd_doctor(root: PathBuf, json: bool) -> Result<()> {
425    let manifest = load_manifest_from_root(&root)?;
426    validate_manifest(&root, &manifest)?;
427    ensure_native_record_command(&manifest.record.mode, "doctor")?;
428    let findings = inspect_surface_states(&root)?;
429    if json {
430        let report = DoctorReport {
431            mode: manifest.record.mode.clone(),
432            status: manifest.record.status.clone(),
433            findings,
434        };
435        println!("{}", serde_json::to_string_pretty(&report)?);
436        return Ok(());
437    }
438
439    println!("dotrepo doctor");
440    println!("- mode: {:?}", manifest.record.mode);
441    println!("- status: {:?}", manifest.record.status);
442    if findings.is_empty() {
443        println!("- no managed-surface findings detected");
444    } else {
445        println!("- conventional surface states:");
446        for finding in findings {
447            println!(
448                "  - {} [{}]: {}",
449                finding.path.display(),
450                format_managed_file_state(finding.state),
451                finding.message
452            );
453        }
454    }
455    Ok(())
456}
457
458pub fn cmd_manage(root: PathBuf, surface: PreviewSurfaceArg, adopt: bool) -> Result<()> {
459    let manifest = load_manifest_from_root(&root)?;
460    validate_manifest(&root, &manifest)?;
461    ensure_native_record_command(&manifest.record.mode, "manage")?;
462
463    if !adopt {
464        return Err(CliExit {
465            code: 2,
466            message: "manage currently requires --adopt".into(),
467        }
468        .into());
469    }
470
471    let plan = adopt_managed_surface(&root, surface.into())?;
472    if let Some(parent) = plan.path.parent() {
473        fs::create_dir_all(parent)?;
474    }
475    fs::write(&plan.path, plan.contents)?;
476    println!(
477        "adopted {} as a partially managed {} surface",
478        plan.path.display(),
479        format_doctor_surface(plan.surface)
480    );
481    Ok(())
482}
483
484pub fn cmd_preview(
485    root: PathBuf,
486    surface: Option<PreviewSurfaceArg>,
487    all: bool,
488    json: bool,
489) -> Result<()> {
490    let manifest = load_manifest_from_root(&root)?;
491    validate_manifest(&root, &manifest)?;
492    ensure_native_record_command(&manifest.record.mode, "preview")?;
493
494    let selected_surfaces = if all {
495        vec![
496            DoctorSurface::Readme,
497            DoctorSurface::Security,
498            DoctorSurface::Contributing,
499            DoctorSurface::Codeowners,
500            DoctorSurface::PullRequestTemplate,
501        ]
502    } else if let Some(surface) = surface {
503        vec![surface.into()]
504    } else {
505        return Err(CliExit {
506            code: 2,
507            message: "preview requires either --surface <name> or --all".into(),
508        }
509        .into());
510    };
511
512    let report = preview_surfaces(&root, &selected_surfaces)?;
513    if json {
514        println!("{}", serde_json::to_string_pretty(&report)?);
515        return Ok(());
516    }
517
518    print_surface_preview_report(&report);
519    Ok(())
520}
521
522pub fn cmd_ci(root: PathBuf, command: CiCommand) -> Result<()> {
523    match command {
524        CiCommand::Init { force, version } => cmd_ci_init(root, force, version),
525    }
526}
527
528fn ensure_native_record_command(mode: &RecordMode, command: &str) -> Result<()> {
529    if *mode == RecordMode::Overlay {
530        return Err(CliExit {
531            code: 2,
532            message: format!(
533                "{} is only supported for native records; found record.mode = \"overlay\"",
534                command
535            ),
536        }
537        .into());
538    }
539
540    Ok(())
541}
542
543pub fn cmd_ci_init(root: PathBuf, force: bool, version: Option<String>) -> Result<()> {
544    let manifest = load_manifest_from_root(&root)?;
545    validate_manifest(&root, &manifest)?;
546    ensure_native_record_command(&manifest.record.mode, "ci init")?;
547
548    let workflow_path = root.join(".github/workflows/dotrepo-check.yml");
549    if workflow_path.exists() && !force {
550        bail!(
551            "{} already exists; rerun with --force to overwrite it",
552            workflow_path.display()
553        );
554    }
555
556    let version = version.unwrap_or_else(|| dotrepo_core::DEFAULT_CI_RELEASE_VERSION.to_string());
557    let workflow = render_dotrepo_ci_workflow(&version);
558    if let Some(parent) = workflow_path.parent() {
559        fs::create_dir_all(parent)?;
560    }
561    fs::write(&workflow_path, workflow)?;
562    println!(
563        "initialized {} with dotrepo {}",
564        workflow_path.display(),
565        version
566    );
567    Ok(())
568}
569
570pub fn cmd_trust(root: PathBuf, json: bool) -> Result<()> {
571    let report = trust_repository(&root)?;
572    if json {
573        println!("{}", serde_json::to_string_pretty(&report)?);
574        return Ok(());
575    }
576
577    println!("{}", format_trust_report(&report));
578    Ok(())
579}
580
581pub fn cmd_adoption_status(root: PathBuf, json: bool) -> Result<()> {
582    let report = adoption_status_repository(&root);
583    if json {
584        println!("{}", serde_json::to_string_pretty(&report)?);
585        return Ok(());
586    }
587
588    println!("adoption status for {}", report.root);
589    if let Some(status) = &report.record_status {
590        println!("- record status: {status}");
591    }
592    if let Some(identity) = &report.repository_identity {
593        println!(
594            "- repository identity: {}/{}/{} ({})",
595            identity.host, identity.owner, identity.repo, identity.url
596        );
597    }
598    for check in &report.checks {
599        let marker = if check.ready { "ok" } else { "needs attention" };
600        println!("- {}: {} - {}", check.name, marker, check.detail);
601    }
602    println!("next steps:");
603    for step in &report.next_steps {
604        println!("- {step}");
605    }
606    Ok(())
607}
608
609pub fn cmd_claim(root: PathBuf, path: PathBuf, json: bool) -> Result<()> {
610    let claim_dir = resolve_claim_directory(
611        &root,
612        path.to_str().context("claim path must be valid UTF-8")?,
613    )?;
614    let report = inspect_claim_directory(&root, &claim_dir)?;
615    if json {
616        println!("{}", serde_json::to_string_pretty(&report)?);
617        return Ok(());
618    }
619
620    println!("{}", format_claim_report(&report));
621    Ok(())
622}
623
624pub struct ClaimInitArgs {
625    pub host: String,
626    pub owner: String,
627    pub repo: String,
628    pub claim_id: String,
629    pub claimant_name: String,
630    pub asserted_role: String,
631    pub contact: Option<String>,
632    pub record_sources: Vec<String>,
633    pub canonical_repo_url: Option<String>,
634    pub review_md: bool,
635    pub force: bool,
636}
637
638pub struct ClaimFromNativeArgs {
639    pub index_root: PathBuf,
640    pub claim_id: String,
641    pub claimant_name: String,
642    pub asserted_role: String,
643    pub contact: Option<String>,
644    pub review_md: bool,
645    pub force: bool,
646}
647
648pub fn cmd_claim_init(root: PathBuf, args: ClaimInitArgs) -> Result<()> {
649    let ClaimInitArgs {
650        host,
651        owner,
652        repo,
653        claim_id,
654        claimant_name,
655        asserted_role,
656        contact,
657        record_sources,
658        canonical_repo_url,
659        review_md,
660        force,
661    } = args;
662
663    let plan = scaffold_claim_directory(
664        &root,
665        &ClaimScaffoldInput {
666            host,
667            owner,
668            repo,
669            claim_id,
670            claimant_display_name: claimant_name,
671            asserted_role,
672            contact,
673            record_sources,
674            canonical_repo_url,
675            create_review_md: review_md,
676            timestamp: current_timestamp()?,
677        },
678    )?;
679
680    if plan.claim_dir.exists() {
681        if !force {
682            bail!(
683                "{} already exists; rerun with --force to replace an empty scaffold",
684                plan.claim_dir.display()
685            );
686        }
687        ensure_replaceable_claim_scaffold(&plan.claim_dir)?;
688        fs::remove_dir_all(&plan.claim_dir)?;
689    }
690
691    fs::create_dir_all(plan.claim_dir.join("events"))?;
692    fs::write(&plan.claim_path, &plan.claim_text)?;
693    println!("initialized {}", plan.claim_path.display());
694    if let (Some(review_path), Some(review_text)) = (&plan.review_path, &plan.review_text) {
695        fs::write(review_path, review_text)?;
696        println!("initialized {}", review_path.display());
697    }
698    Ok(())
699}
700
701pub fn cmd_claim_from_native(root: PathBuf, args: ClaimFromNativeArgs) -> Result<()> {
702    let ClaimFromNativeArgs {
703        index_root,
704        claim_id,
705        claimant_name,
706        asserted_role,
707        contact,
708        review_md,
709        force,
710    } = args;
711    let manifest = load_manifest_from_root(&root)?;
712    let identity = native_repository_identity(&manifest)?;
713
714    cmd_claim_init(
715        index_root,
716        ClaimInitArgs {
717            host: identity.host,
718            owner: identity.owner,
719            repo: identity.repo,
720            claim_id,
721            claimant_name,
722            asserted_role,
723            contact,
724            record_sources: vec![identity.url.clone()],
725            canonical_repo_url: Some(identity.url),
726            review_md,
727            force,
728        },
729    )
730}
731
732pub struct ClaimEventArgs {
733    pub path: PathBuf,
734    pub kind: ClaimEventKindArg,
735    pub actor: String,
736    pub summary: String,
737    pub corrected_state: Option<CorrectedClaimStateArg>,
738    pub canonical_record_path: Option<String>,
739    pub canonical_mirror_path: Option<String>,
740}
741
742pub struct ClaimSubmitNativeArgs {
743    pub index_root: PathBuf,
744    pub claim_id: String,
745    pub actor: String,
746    pub summary: String,
747}
748
749pub struct ClaimAcceptNativeArgs {
750    pub index_root: PathBuf,
751    pub path: Option<PathBuf>,
752    pub claim_id: Option<String>,
753    pub actor: String,
754    pub summary: String,
755}
756
757fn native_claim_path(identity: &AdoptionRepositoryIdentity, claim_id: &str) -> PathBuf {
758    PathBuf::from(format!(
759        "repos/{}/{}/{}/claims/{}",
760        identity.host, identity.owner, identity.repo, claim_id
761    ))
762}
763
764pub fn cmd_claim_event(root: PathBuf, args: ClaimEventArgs) -> Result<()> {
765    let ClaimEventArgs {
766        path,
767        kind,
768        actor,
769        summary,
770        corrected_state,
771        canonical_record_path,
772        canonical_mirror_path,
773    } = args;
774
775    let claim_dir = resolve_claim_directory(
776        &root,
777        path.to_str().context("claim path must be valid UTF-8")?,
778    )?;
779    let plan = append_claim_event(
780        &root,
781        &claim_dir,
782        &ClaimEventAppendInput {
783            kind: kind.into(),
784            actor,
785            summary,
786            timestamp: current_timestamp()?,
787            corrected_state: corrected_state.map(Into::into),
788            canonical_record_path,
789            canonical_mirror_path,
790        },
791    )?;
792
793    fs::create_dir_all(claim_dir.join("events"))?;
794    fs::write(&plan.event_path, &plan.event_text)?;
795    fs::write(&plan.claim_path, &plan.claim_text)?;
796    println!("updated {}", plan.claim_path.display());
797    println!("appended {}", plan.event_path.display());
798    Ok(())
799}
800
801pub fn cmd_claim_submit_native(root: PathBuf, args: ClaimSubmitNativeArgs) -> Result<()> {
802    let ClaimSubmitNativeArgs {
803        index_root,
804        claim_id,
805        actor,
806        summary,
807    } = args;
808    let manifest = load_manifest_from_root(&root)?;
809    let identity = native_repository_identity(&manifest)?;
810    cmd_claim_event(
811        index_root,
812        ClaimEventArgs {
813            path: native_claim_path(&identity, &claim_id),
814            kind: ClaimEventKindArg::Submitted,
815            actor,
816            summary,
817            corrected_state: None,
818            canonical_record_path: None,
819            canonical_mirror_path: None,
820        },
821    )
822}
823
824pub fn cmd_claim_accept_native(root: PathBuf, args: ClaimAcceptNativeArgs) -> Result<()> {
825    let ClaimAcceptNativeArgs {
826        index_root,
827        path,
828        claim_id,
829        actor,
830        summary,
831    } = args;
832    let manifest = load_manifest_from_root(&root)?;
833    let identity = native_repository_identity(&manifest)?;
834    let (path, explicit_path) = match (path, claim_id) {
835        (Some(path), None) => (path, true),
836        (None, Some(claim_id)) => (native_claim_path(&identity, &claim_id), false),
837        (Some(_), Some(_)) => {
838            bail!("claim-accept-native accepts either a claim path or --claim-id, not both")
839        }
840        (None, None) => bail!("claim-accept-native requires a claim path or --claim-id"),
841    };
842    if explicit_path {
843        validate_claim_path_matches_native_identity(&path, &identity)?;
844    }
845    let canonical_mirror_path = canonical_mirror_path_for_claim_path(&path)?;
846    cmd_claim_event(
847        index_root,
848        ClaimEventArgs {
849            path,
850            kind: ClaimEventKindArg::Accepted,
851            actor,
852            summary,
853            corrected_state: None,
854            canonical_record_path: Some(".repo".into()),
855            canonical_mirror_path: Some(canonical_mirror_path),
856        },
857    )
858}
859
860pub fn cmd_public(command: PublicCommand) -> Result<()> {
861    match command {
862        PublicCommand::Summary {
863            index_root,
864            host,
865            owner,
866            repo,
867            base_path,
868            stale_after_hours,
869        } => {
870            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
871            print_public_response(public_repository_summary_or_error_with_base(
872                &index_root,
873                &host,
874                &owner,
875                &repo,
876                freshness,
877                &base_path,
878            ))
879        }
880        PublicCommand::Profile {
881            index_root,
882            host,
883            owner,
884            repo,
885            base_path,
886            stale_after_hours,
887        } => {
888            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
889            print_public_response(public_repository_profile_or_error_with_base(
890                &index_root,
891                &host,
892                &owner,
893                &repo,
894                freshness,
895                &base_path,
896            ))
897        }
898        PublicCommand::Trust {
899            index_root,
900            host,
901            owner,
902            repo,
903            base_path,
904            stale_after_hours,
905        } => {
906            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
907            print_public_response(public_repository_trust_or_error_with_base(
908                &index_root,
909                &host,
910                &owner,
911                &repo,
912                freshness,
913                &base_path,
914            ))
915        }
916        PublicCommand::BatchProfiles {
917            index_root,
918            repos,
919            base_path,
920            stale_after_hours,
921        } => {
922            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
923            let identities = parse_public_repository_args(&repos)?;
924            let response = public_repository_batch_profiles_with_base(
925                &index_root,
926                &identities,
927                freshness,
928                &base_path,
929            )?;
930            println!("{}", serde_json::to_string_pretty(&response)?);
931            Ok(())
932        }
933        PublicCommand::BatchQuery {
934            index_root,
935            repos,
936            paths,
937            base_path,
938            stale_after_hours,
939        } => {
940            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
941            let identities = parse_public_repository_args(&repos)?;
942            let response = public_repository_batch_query_with_base(
943                &index_root,
944                &identities,
945                &paths,
946                freshness,
947                &base_path,
948            )?;
949            println!("{}", serde_json::to_string_pretty(&response)?);
950            Ok(())
951        }
952        PublicCommand::Compare {
953            index_root,
954            repos,
955            base_path,
956            stale_after_hours,
957        } => {
958            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
959            let identities = parse_public_repository_args(&repos)?;
960            let response =
961                public_profile_compare_with_base(&index_root, &identities, freshness, &base_path)?;
962            println!("{}", serde_json::to_string_pretty(&response)?);
963            Ok(())
964        }
965        PublicCommand::Relations {
966            index_root,
967            host,
968            owner,
969            repo,
970            base_path,
971            stale_after_hours,
972        } => {
973            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
974            print_public_response(Ok(public_repository_relations_with_base(
975                &index_root,
976                &host,
977                &owner,
978                &repo,
979                freshness,
980                &base_path,
981            )?))
982        }
983        PublicCommand::Search {
984            index_root,
985            q,
986            languages,
987            topics,
988            statuses,
989            confidences,
990            require_build,
991            require_test,
992            require_docs,
993            require_security_contact,
994            require_license,
995            limit,
996            base_path,
997            stale_after_hours,
998        } => {
999            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
1000            let response = public_profile_search_with_base(
1001                &index_root,
1002                PublicProfileSearchOptions {
1003                    query: q,
1004                    languages,
1005                    topics,
1006                    statuses,
1007                    confidences,
1008                    require_build,
1009                    require_test,
1010                    require_docs,
1011                    require_security_contact,
1012                    require_license,
1013                    limit,
1014                },
1015                freshness,
1016                &base_path,
1017            )?;
1018            println!("{}", serde_json::to_string_pretty(&response)?);
1019            Ok(())
1020        }
1021        PublicCommand::Query {
1022            index_root,
1023            host,
1024            owner,
1025            repo,
1026            path,
1027            base_path,
1028            stale_after_hours,
1029        } => {
1030            let freshness = current_public_freshness(&index_root, stale_after_hours)?;
1031            print_public_response(public_repository_query_or_error_with_base(
1032                &index_root,
1033                &host,
1034                &owner,
1035                &repo,
1036                &path,
1037                freshness,
1038                &base_path,
1039            ))
1040        }
1041        PublicCommand::Export {
1042            index_root,
1043            out_dir,
1044            base_path,
1045            stale_after_hours,
1046            generated_at,
1047            stale_after,
1048            pagedigest_previous,
1049        } => {
1050            let snapshot_digest = index_snapshot_digest(&index_root)?;
1051            let freshness = build_public_freshness_with_digest(
1052                &index_root,
1053                stale_after_hours,
1054                generated_at.as_deref(),
1055                stale_after.as_deref(),
1056                Some(&snapshot_digest),
1057            )?;
1058            let outputs = export_public_index_static_with_options(
1059                &index_root,
1060                &out_dir,
1061                freshness,
1062                &base_path,
1063                pagedigest_previous.as_deref(),
1064            )?;
1065            for (path, contents) in outputs {
1066                if let Some(parent) = path.parent() {
1067                    fs::create_dir_all(parent)?;
1068                }
1069                fs::write(&path, contents)?;
1070                println!("exported {}", path.display());
1071            }
1072            Ok(())
1073        }
1074    }
1075}
1076
1077fn parse_public_repository_args(values: &[String]) -> Result<Vec<PublicRepositoryIdentity>> {
1078    values
1079        .iter()
1080        .map(|value| parse_public_repository_arg(value))
1081        .collect()
1082}
1083
1084fn parse_public_repository_arg(value: &str) -> Result<PublicRepositoryIdentity> {
1085    let trimmed = value.trim();
1086    let without_scheme = trimmed
1087        .strip_prefix("https://")
1088        .or_else(|| trimmed.strip_prefix("http://"))
1089        .unwrap_or(trimmed);
1090    let without_git = without_scheme.trim_end_matches(".git");
1091    let parts = without_git
1092        .split('/')
1093        .filter(|part| !part.is_empty())
1094        .collect::<Vec<_>>();
1095    if parts.len() != 3 {
1096        bail!("repository must be host/owner/repo or https://host/owner/repo: {value}");
1097    }
1098    Ok(PublicRepositoryIdentity {
1099        host: parts[0].to_string(),
1100        owner: parts[1].to_string(),
1101        repo: parts[2].to_string(),
1102        source: None,
1103    })
1104}
1105
1106fn print_public_response<T: Serialize>(
1107    response: std::result::Result<T, PublicErrorResponse>,
1108) -> Result<()> {
1109    match response {
1110        Ok(response) => {
1111            println!("{}", serde_json::to_string_pretty(&response)?);
1112            Ok(())
1113        }
1114        Err(response) => {
1115            println!("{}", serde_json::to_string_pretty(&response)?);
1116            Err(CliExit {
1117                code: 1,
1118                message: String::new(),
1119            }
1120            .into())
1121        }
1122    }
1123}
1124
1125fn current_timestamp() -> Result<String> {
1126    current_timestamp_rfc3339()
1127}
1128
1129fn ensure_replaceable_claim_scaffold(claim_dir: &std::path::Path) -> Result<()> {
1130    if !claim_dir.is_dir() {
1131        bail!(
1132            "{} exists but is not a claim directory",
1133            claim_dir.display()
1134        );
1135    }
1136
1137    for entry in fs::read_dir(claim_dir)? {
1138        let entry = entry?;
1139        let path = entry.path();
1140        let name = entry.file_name();
1141        let name = name.to_string_lossy();
1142        match name.as_ref() {
1143            "claim.toml" | "review.md" => {}
1144            "events" => {
1145                if !path.is_dir() {
1146                    bail!(
1147                        "{} must be a directory before it can be replaced",
1148                        path.display()
1149                    );
1150                }
1151                if fs::read_dir(&path)?.next().is_some() {
1152                    bail!(
1153                        "refusing to overwrite existing claim history in {}",
1154                        path.display()
1155                    );
1156                }
1157            }
1158            _ => {
1159                bail!(
1160                    "refusing to overwrite unexpected claim scaffold contents in {}",
1161                    path.display()
1162                );
1163            }
1164        }
1165    }
1166
1167    Ok(())
1168}