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 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}