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