Skip to main content

systemprompt_cli/commands/core/services/
validate.rs

1//! `services validate` — load a services tree the way an instance would.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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}