systemprompt_cli/commands/core/services/
validate.rs1use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9use clap::Args;
10use serde::Serialize;
11use systemprompt_loader::ConfigLoader;
12use systemprompt_loader::bundle::BundleCache;
13use systemprompt_marketplace::CatalogContent;
14
15use super::staging::{StagedBundle, compose_staged, stage_archive, stage_tree};
16use super::versions::detect_drift;
17use crate::shared::CommandOutput;
18
19const VALIDATE_API_URL: &str = "http://localhost";
20const CONFIG_RELPATH: &str = "config/config.yaml";
21
22#[derive(Debug, Clone, Args)]
23pub struct ValidateArgs {
24 #[arg(long, help = "Services tree to validate")]
25 pub root: PathBuf,
26
27 #[arg(long, help = "Base bundle to compose underneath the tree")]
28 pub base: Option<PathBuf>,
29
30 #[arg(long, help = "Previous bundle to compare plugin versions against")]
31 pub against: Option<PathBuf>,
32
33 #[arg(long, help = "Treat warnings as failures")]
34 pub strict: bool,
35}
36
37#[derive(Debug, Serialize)]
38pub struct Finding {
39 pub check: String,
40 pub status: String,
41 pub detail: String,
42}
43
44impl Finding {
45 fn pass(check: &str, detail: impl Into<String>) -> Self {
46 Self::new(check, "pass", detail)
47 }
48
49 fn warn(check: &str, detail: impl Into<String>) -> Self {
50 Self::new(check, "warn", detail)
51 }
52
53 fn fail(check: &str, detail: impl Into<String>) -> Self {
54 Self::new(check, "fail", detail)
55 }
56
57 fn new(check: &str, status: &str, detail: impl Into<String>) -> Self {
58 Self {
59 check: check.to_owned(),
60 status: status.to_owned(),
61 detail: detail.into(),
62 }
63 }
64}
65
66pub fn execute(args: &ValidateArgs) -> Result<(CommandOutput, bool)> {
67 let findings = run(args)?;
68 let blocking = findings.iter().any(|f| f.status == "fail")
69 || (args.strict && findings.iter().any(|f| f.status == "warn"));
70 let output = CommandOutput::table_of(vec!["check", "status", "detail"], &findings)
71 .with_title("Services Validation");
72 Ok((output, !blocking))
73}
74
75pub fn run(args: &ValidateArgs) -> Result<Vec<Finding>> {
76 let scratch = tempfile::tempdir().context("Failed to create a scratch directory")?;
77 let cache = BundleCache::new(scratch.path().join("cache"));
78
79 let mut findings = Vec::new();
80 let mut members: Vec<StagedBundle> = Vec::new();
81 if let Some(base) = args.base.as_deref() {
82 members.push(stage_archive(&cache, "base", base)?);
83 }
84 let tree = stage_tree(&cache, "tree", &args.root, scratch.path())?;
85 let tree_root = tree.root.clone();
86 let tree_manifest = tree.manifest.clone();
87 members.push(tree);
88
89 let composed = if members.len() > 1 {
90 match compose_staged(&cache, &members) {
91 Ok(root) => {
92 findings.push(Finding::pass(
93 "compose",
94 "base and tree compose without collisions",
95 ));
96 root
97 },
98 Err(err) => {
99 findings.push(Finding::fail("compose", err.to_string()));
100 return Ok(findings);
101 },
102 }
103 } else {
104 tree_root
105 };
106
107 findings.extend(load_findings(&composed));
108
109 if let Some(previous) = args.against.as_deref() {
110 let staged = stage_archive(&cache, "previous", previous)?;
111 let drift = detect_drift(&staged.manifest, &staged.root, &tree_manifest, &args.root)?;
112 findings.push(drift_finding(&drift));
113 }
114
115 Ok(findings)
116}
117
118fn load_findings(root: &Path) -> Vec<Finding> {
119 if !root.join(CONFIG_RELPATH).is_file() {
120 return vec![Finding::fail(
121 "config",
122 format!(
123 "no {CONFIG_RELPATH} in the composed root; a marketplace-only tree carries none, \
124 so validate it with --base <platform bundle>"
125 ),
126 )];
127 }
128 let services = match ConfigLoader::load_from_path(&root.join(CONFIG_RELPATH)) {
129 Ok(services) => services,
130 Err(err) => return vec![Finding::fail("config", err.to_string())],
131 };
132 let mut findings = vec![Finding::pass("config", "services config loads")];
133
134 match CatalogContent::load(&services, root, VALIDATE_API_URL) {
135 Ok(catalog) => {
136 let content = catalog.as_content();
137 findings.push(Finding::pass(
138 "catalog",
139 format!(
140 "{} skill(s), {} agent(s), {} artifact(s), {} rule(s)",
141 content.skills.len(),
142 content.agents.len(),
143 content.artifacts.len(),
144 content.rules.len()
145 ),
146 ));
147 },
148 Err(err) => findings.push(Finding::fail("catalog", err.to_string())),
149 }
150 findings
151}
152
153fn drift_finding(drift: &[super::versions::VersionDrift]) -> Finding {
154 if drift.is_empty() {
155 return Finding::pass("versions", "every changed plugin bumped its version");
156 }
157 let detail = drift
158 .iter()
159 .map(|d| {
160 format!(
161 "{} still at {} with {} changed file(s)",
162 d.plugin, d.version, d.changed_files
163 )
164 })
165 .collect::<Vec<_>>()
166 .join("; ");
167 Finding::warn("versions", detail)
168}