1use anyhow::{Context, Result};
4use std::{
5 io::{self, Write},
6 path::{Path, PathBuf},
7};
8
9use crate::{
10 analysis_cache,
11 cargo_context::CargoOptions,
12 diagnostics::{self, MessageFormat},
13 discover::{self, ScanOptions},
14 extract::Extractor,
15 load,
16 model_baseline,
17 model_ir::ModelIr,
18 query::{self, QueryKind},
19 verify,
20};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum OutputFormat {
25 Json,
26 Tree,
27}
28
29#[derive(Debug, Clone)]
31pub enum ReportMode {
32 FullIr {
33 format: OutputFormat,
34 },
35 Query {
36 kind: String,
37 selector: Option<String>,
38 to: Option<String>,
39 limit: usize,
40 offset: usize,
41 format: OutputFormat,
42 },
43}
44
45#[derive(Debug, Clone)]
47pub struct AnalyzeRequest {
48 pub path: PathBuf,
49 pub cargo: CargoOptions,
50 pub runtime_trace: Option<PathBuf>,
51 pub component_root: Option<String>,
52 pub dataflow: bool,
53 pub heuristic_architecture: bool,
54 pub use_cache: bool,
55}
56
57#[derive(Debug, Clone)]
59pub struct AuditBundle {
60 pub output_dir: PathBuf,
61 pub checkpoint: Option<PathBuf>,
62 pub verify_root: Option<String>,
63 pub legacy_root: Option<String>,
64 pub legacy_entry: Option<String>,
65 pub deny_rules: Vec<String>,
66 pub strict: bool,
67}
68
69#[derive(Debug, Clone)]
71pub struct ModelRun {
72 pub analyze: AnalyzeRequest,
73 pub report: ReportMode,
74 pub output: Option<PathBuf>,
75 pub diagnostics: Option<MessageFormat>,
77 pub fail_on_warning_error: bool,
79 pub deny_rules: Vec<String>,
81 pub check_baseline: Option<PathBuf>,
82 pub update_baseline: Option<PathBuf>,
83 pub audit: Option<AuditBundle>,
84}
85
86pub fn analyze_model(request: &AnalyzeRequest) -> Result<ModelIr> {
88 if analysis_cache::cache_enabled(request.use_cache) {
89 let canonical = request
90 .path
91 .canonicalize()
92 .unwrap_or_else(|_| request.path.clone());
93 let key = format!("{canonical:?}|{:?}", request.cargo);
94 let cache_path = analysis_cache::cache_path(&key);
95 if let Some(cached) = analysis_cache::load(&cache_path)? {
96 return Ok(cached);
97 }
98 let model = analyze_model_uncached(request)?;
99 analysis_cache::save(&cache_path, &model)?;
100 return Ok(model);
101 }
102 analyze_model_uncached(request)
103}
104
105fn analyze_model_uncached(request: &AnalyzeRequest) -> Result<ModelIr> {
106 let options = ScanOptions {
107 cargo: request.cargo.clone(),
108 runtime_trace: request.runtime_trace.clone(),
109 component_root: request.component_root.clone(),
110 dataflow: request.dataflow,
111 heuristic_architecture: request.heuristic_architecture,
112 };
113 discover::analyze(&request.path, &options)
114}
115
116pub fn render_report(model: &ModelIr, report: &ReportMode) -> Result<String> {
118 match report {
119 ReportMode::FullIr { format } => match format {
120 OutputFormat::Json => Ok(serde_json::to_string_pretty(model)? + "\n"),
121 OutputFormat::Tree => {
122 let request = query::QueryRequest::new(query::QueryKind::Summary);
123 Ok(query::render_text(&query::execute(model, &request)?))
124 }
125 },
126 ReportMode::Query {
127 kind,
128 selector,
129 to,
130 limit,
131 offset,
132 format,
133 } => {
134 let mut request = query::QueryRequest::new(kind.parse()?);
135 request.selector = selector.clone();
136 request.to = to.clone();
137 request.limit = *limit;
138 request.offset = *offset;
139 let response = query::execute(model, &request)?;
140 match format {
141 OutputFormat::Json => Ok(serde_json::to_string_pretty(&response)? + "\n"),
142 OutputFormat::Tree => Ok(query::render_text(&response)),
143 }
144 }
145 }
146}
147
148fn write_query(model: &ModelIr, kind: QueryKind, path: &Path) -> Result<()> {
149 let request = query::QueryRequest::new(kind);
150 let response = query::execute(model, &request)?;
151 let rendered = serde_json::to_string_pretty(&response)? + "\n";
152 write_output(Some(path), rendered.as_bytes())
153}
154
155fn write_legacy_world_model(
156 request: &AnalyzeRequest,
157 legacy_root: &str,
158 legacy_entry: Option<&str>,
159 path: &Path,
160) -> Result<()> {
161 let cargo_context =
162 crate::cargo_context::CargoContext::discover(&request.path, &request.cargo).ok();
163 let mut krate = match cargo_context.as_ref() {
164 Some(context) => {
165 let roots = context.selected_source_roots(request.cargo.package_target.as_deref())?;
166 load::load_from_roots(&request.path, &roots)?
167 }
168 None => load::load(&request.path)?,
169 };
170 if let Some(context) = cargo_context.as_ref() {
171 krate.set_dependency_aliases(context.dependency_aliases.clone());
172 }
173 let candle_nn_version = cargo_context.as_ref().and_then(|context| {
174 crate::op_semantics::matched_candle_version(
175 context
176 .candle_versions
177 .get("candle-core")
178 .map(String::as_str),
179 context.candle_versions.get("candle-nn").map(String::as_str),
180 )
181 .map(str::to_string)
182 });
183 let structure =
184 Extractor::for_candle_version(&krate, candle_nn_version.as_deref()).run(legacy_root, None)?;
185 let mut payload = serde_json::json!({
186 "root": legacy_root,
187 "parameters": structure.params.len(),
188 "parameter_keys": structure.params.iter().map(|p| p.key.to_string()).collect::<Vec<_>>(),
189 });
190 if let Some(entry) = legacy_entry {
191 if let Ok(graph) =
192 crate::dataflow::analyze_with_candle_version(&krate, entry, candle_nn_version.as_deref())
193 {
194 payload["entry"] = entry.into();
195 payload["dataflow_nodes"] = graph.nodes.len().into();
196 }
197 }
198 write_output(
199 Some(path),
200 (serde_json::to_string_pretty(&payload)? + "\n").as_bytes(),
201 )
202}
203
204fn write_checkpoint_audit(
205 request: &AnalyzeRequest,
206 model: &ModelIr,
207 checkpoint: &Path,
208 verify_root: Option<&str>,
209 path: &Path,
210) -> Result<()> {
211 let header = verify::read_header(checkpoint)?;
212 let root = verify_root
213 .or_else(|| model.components.first().map(|c| c.builders.first().map(|b| b.name.as_str())).flatten())
214 .unwrap_or("vb");
215 let mut structure = legacy_structure_for_verify(request, root)?;
216 let report = verify::verify(&mut structure, &header, root);
217 write_output(
218 Some(path),
219 (serde_json::to_string_pretty(&report)? + "\n").as_bytes(),
220 )
221}
222
223fn legacy_structure_for_verify(request: &AnalyzeRequest, root: &str) -> Result<crate::ir::Structure> {
224 let cargo_context =
225 crate::cargo_context::CargoContext::discover(&request.path, &request.cargo).ok();
226 let mut krate = match cargo_context.as_ref() {
227 Some(context) => {
228 let roots = context.selected_source_roots(request.cargo.package_target.as_deref())?;
229 load::load_from_roots(&request.path, &roots)?
230 }
231 None => load::load(&request.path)?,
232 };
233 if let Some(context) = cargo_context.as_ref() {
234 krate.set_dependency_aliases(context.dependency_aliases.clone());
235 }
236 let candle_nn_version = cargo_context.as_ref().and_then(|context| {
237 crate::op_semantics::matched_candle_version(
238 context
239 .candle_versions
240 .get("candle-core")
241 .map(String::as_str),
242 context.candle_versions.get("candle-nn").map(String::as_str),
243 )
244 .map(str::to_string)
245 });
246 Extractor::for_candle_version(&krate, candle_nn_version.as_deref()).run(root, None)
247}
248
249fn run_audit_bundle(model: &ModelIr, request: &AnalyzeRequest, audit: &AuditBundle) -> Result<()> {
250 std::fs::create_dir_all(&audit.output_dir)
251 .with_context(|| format!("creating {}", audit.output_dir.display()))?;
252 write_query(model, QueryKind::Summary, &audit.output_dir.join("summary.json"))?;
253 write_query(model, QueryKind::Doctor, &audit.output_dir.join("doctor.json"))?;
254 write_query(
255 model,
256 QueryKind::ModelImprovement,
257 &audit.output_dir.join("model-improvement.json"),
258 )?;
259 write_query(model, QueryKind::Findings, &audit.output_dir.join("findings.json"))?;
260 let rendered = render_report(model, &ReportMode::FullIr {
261 format: OutputFormat::Json,
262 })?;
263 write_output(
264 Some(&audit.output_dir.join("model-ir.json")),
265 rendered.as_bytes(),
266 )?;
267 if let Some(runtime) = &request.runtime_trace {
268 write_query(model, QueryKind::Runtime, &audit.output_dir.join("runtime.json"))?;
269 let _ = runtime;
270 }
271 if let Some(root) = audit.legacy_root.as_deref() {
272 write_legacy_world_model(
273 request,
274 root,
275 audit.legacy_entry.as_deref(),
276 &audit.output_dir.join("world-model.json"),
277 )?;
278 }
279 if let Some(checkpoint) = &audit.checkpoint {
280 write_checkpoint_audit(
281 request,
282 model,
283 checkpoint,
284 audit.verify_root.as_deref(),
285 &audit.output_dir.join("checkpoint.json"),
286 )?;
287 }
288 Ok(())
289}
290
291fn enforce_exit_policy(model: &ModelIr, run: &ModelRun) -> Result<()> {
292 if run.fail_on_warning_error && diagnostics::has_proven_defect_findings(model) {
293 anyhow::bail!(
294 "strict: unified model analysis contains proven error findings \
295 (coverage gaps and non-proven warnings do not fail)"
296 );
297 }
298 let denied = diagnostics::denied_findings(model, &run.deny_rules);
299 if !denied.is_empty() {
300 let rules: Vec<_> = denied.iter().map(|f| f.rule.as_str()).collect();
301 anyhow::bail!(
302 "deny: proven findings matched blocked rules: {}",
303 rules.join(", ")
304 );
305 }
306 if run.audit.as_ref().is_some_and(|a| a.strict)
307 && (diagnostics::has_proven_defect_findings(model)
308 || diagnostics::has_denied_findings(model, &run.deny_rules))
309 {
310 anyhow::bail!("audit strict gate failed");
311 }
312 Ok(())
313}
314
315pub fn run_model(run: &ModelRun) -> Result<()> {
317 let model = analyze_model(&run.analyze)?;
318
319 if let Some(path) = &run.update_baseline {
320 model_baseline::update(&model, path)
321 .with_context(|| format!("updating model baseline {}", path.display()))?;
322 eprintln!("updated model baseline {}", path.display());
323 }
324 if let Some(path) = &run.check_baseline {
325 model_baseline::check(&model, path)
326 .with_context(|| format!("checking model baseline {}", path.display()))?;
327 }
328
329 if let Some(audit) = &run.audit {
330 run_audit_bundle(&model, &run.analyze, audit)?;
331 }
332
333 let rendered = render_report(&model, &run.report)?;
334 if run.audit.is_none() || run.output.is_some() {
335 write_output(run.output.as_deref(), rendered.as_bytes())?;
336 }
337
338 if let Some(format) = run.diagnostics {
339 let diagnostics = diagnostics::from_model(&model);
340 let text = diagnostics::render(&diagnostics, format);
341 if !text.is_empty() {
342 eprint!("{text}");
343 }
344 }
345
346 enforce_exit_policy(&model, run)?;
347 Ok(())
348}
349
350pub fn resolve_package_path(path: Option<&Path>, manifest_path: Option<&Path>) -> Result<PathBuf> {
352 if let Some(manifest) = manifest_path {
353 if manifest
354 .file_name()
355 .is_some_and(|name| name == "Cargo.toml")
356 {
357 return Ok(manifest
358 .parent()
359 .map(Path::to_path_buf)
360 .unwrap_or_else(|| PathBuf::from(".")));
361 }
362 return Ok(manifest.to_path_buf());
363 }
364 Ok(path.map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")))
365}
366
367pub fn write_output(path: Option<&Path>, bytes: &[u8]) -> Result<()> {
368 match path {
369 Some(path) => {
370 if let Some(parent) = path
371 .parent()
372 .filter(|parent| !parent.as_os_str().is_empty())
373 {
374 std::fs::create_dir_all(parent)
375 .with_context(|| format!("creating {}", parent.display()))?;
376 }
377 std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
378 }
379 None => io::stdout().lock().write_all(bytes)?,
380 }
381 Ok(())
382}