1use std::fs;
2
3use plexus_engine::EngineCapabilities;
4use plexus_serde::{deserialize_plan, Plan};
5
6use crate::cli::{binary_name, parse_cli, usage, Command};
7use crate::diff::diff_plans;
8use crate::explain::explain_plan;
9use crate::lint::lint_plan;
10use crate::validate::validate_plan;
11use crate::viz::{plan_to_dot, render_dot_to_svg};
12
13pub fn run_cli(args: Vec<String>) -> Result<(), String> {
14 let parsed = parse_cli(&args)?;
15 let bin_name = binary_name(&args);
16 let command_args = &parsed.command_args;
17
18 match parsed.command {
19 Command::Inspect => {
20 let in_path = flag_value(command_args, "--in").ok_or_else(|| usage(bin_name))?;
21 let plan = read_plan(&in_path)?;
22 let format = flag_value(command_args, "--format").unwrap_or_else(|| "text".to_string());
23 if format == "dot" {
24 let dot = plan_to_dot(&plan);
25 return write_or_stdout(
26 dot.as_bytes(),
27 flag_value(command_args, "--out").as_deref(),
28 );
29 }
30 let report = explain_plan(&plan);
31 print_report(&format, &report)
32 }
33 Command::Diff => {
34 let old_path = flag_value(command_args, "--old").ok_or_else(|| usage(bin_name))?;
35 let new_path = flag_value(command_args, "--new").ok_or_else(|| usage(bin_name))?;
36 let format = flag_value(command_args, "--format").unwrap_or_else(|| "text".to_string());
37 let old = read_plan(&old_path)?;
38 let new = read_plan(&new_path)?;
39 let report = diff_plans(&old, &new);
40 print_report(&format, &report)
41 }
42 Command::Validate => {
43 let in_path = flag_value(command_args, "--in").ok_or_else(|| usage(bin_name))?;
44 let format = flag_value(command_args, "--format").unwrap_or_else(|| "text".to_string());
45 let plan = read_plan(&in_path)?;
46 let capabilities = flag_value(command_args, "--caps-json")
47 .map(|path| read_capabilities_json(&path))
48 .transpose()?;
49 let report = validate_plan(&plan, capabilities.as_ref());
50 print_report(&format, &report)
51 }
52 Command::Lint => {
53 let in_path = flag_value(command_args, "--in").ok_or_else(|| usage(bin_name))?;
54 let format = flag_value(command_args, "--format").unwrap_or_else(|| "text".to_string());
55 let plan = read_plan(&in_path)?;
56 let report = lint_plan(&plan);
57 print_report(&format, &report)
58 }
59 Command::Viz => {
60 let in_path = flag_value(command_args, "--in").ok_or_else(|| usage(bin_name))?;
61 let dot = plan_to_dot(&read_plan(&in_path)?);
62 let out = flag_value(command_args, "--out");
63 let format = flag_value(command_args, "--format")
64 .or_else(|| out.as_ref().and_then(|p| infer_format_from_path(p)))
65 .unwrap_or_else(|| "dot".to_string());
66 match format.as_str() {
67 "dot" => write_or_stdout(dot.as_bytes(), out.as_deref())?,
68 "svg" => {
69 let svg = render_dot_to_svg(dot.as_bytes())?;
70 write_or_stdout(&svg, out.as_deref())?;
71 }
72 _ => return Err("unsupported viz --format, expected dot|svg".to_string()),
73 };
74 Ok(())
75 }
76 }
77}
78
79fn read_plan(path: &str) -> Result<Plan, String> {
80 let bytes = fs::read(path).map_err(|e| format!("failed reading {path}: {e}"))?;
81 deserialize_plan(&bytes).map_err(|e| format!("failed to deserialize {path}: {e}"))
82}
83
84fn read_capabilities_json(path: &str) -> Result<EngineCapabilities, String> {
85 let text = fs::read_to_string(path).map_err(|e| format!("failed reading {path}: {e}"))?;
86 EngineCapabilities::from_json(&text)
87 .map_err(|e| format!("failed to parse capability JSON {path}: {e}"))
88}
89
90fn flag_value(args: &[String], flag: &str) -> Option<String> {
91 args.iter()
92 .position(|x| x == flag)
93 .and_then(|idx| args.get(idx + 1))
94 .cloned()
95}
96
97fn print_report<T: serde::Serialize + std::fmt::Debug>(
98 format: &str,
99 report: &T,
100) -> Result<(), String> {
101 match format {
102 "json" => {
103 let json = serde_json::to_string_pretty(report)
104 .map_err(|e| format!("failed to serialize JSON report: {e}"))?;
105 println!("{json}");
106 }
107 "text" => {
108 println!("{report:#?}");
109 }
110 _ => {
111 return Err(format!(
112 "unsupported --format '{format}', expected text|json|dot"
113 ))
114 }
115 }
116 Ok(())
117}
118
119fn write_or_stdout(bytes: &[u8], out_path: Option<&str>) -> Result<(), String> {
120 if let Some(path) = out_path {
121 fs::write(path, bytes).map_err(|e| format!("failed writing {path}: {e}"))?;
122 } else {
123 print!("{}", String::from_utf8_lossy(bytes));
124 }
125 Ok(())
126}
127
128fn infer_format_from_path(path: &str) -> Option<String> {
129 if path.ends_with(".svg") {
130 Some("svg".to_string())
131 } else if path.ends_with(".dot") {
132 Some("dot".to_string())
133 } else {
134 None
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use plexus_engine::{EngineCapabilities, ExprKind, OpKind, PlanSemver, VersionRange};
142 use plexus_ir::mlir_text_to_plan;
143 use plexus_serde::serialize_plan;
144 use std::fs;
145 use std::path::Path;
146
147 fn temp_path(name: &str, ext: &str) -> std::path::PathBuf {
148 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
149 std::env::temp_dir().join(format!(
150 "plexus-devtools-{name}-{}-{}.{}",
151 std::process::id(),
152 COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
153 ext
154 ))
155 }
156
157 fn embedded_fixture_plan_bytes() -> Vec<u8> {
158 let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
159 .join("../plexus-engine/src/tests/fixtures/vector/003_embedded_graphrag.feature");
160 let text = fs::read_to_string(&fixture).expect("read embedded GraphRAG fixture");
161 let start = text
162 .find("func.func @embedded_graphrag_vector_first()")
163 .expect("fixture plan start");
164 let end = text[start..]
165 .find("\"\"\"")
166 .map(|offset| start + offset)
167 .expect("fixture plan end");
168 let plan_mlir = &text[start..end];
169 let plan = mlir_text_to_plan(plan_mlir).expect("parse fixture plan");
170 serialize_plan(&plan).expect("serialize fixture plan")
171 }
172
173 #[test]
174 fn inspect_binary_smoke_runs_against_embedded_fixture_plan() {
175 let plan_path = temp_path("inspect-smoke-plan", "plexus");
176 fs::write(&plan_path, embedded_fixture_plan_bytes()).expect("write plan bytes");
177
178 let result = run_cli(vec![
179 "plexus-inspect".to_string(),
180 "--in".to_string(),
181 plan_path.display().to_string(),
182 "--format".to_string(),
183 "json".to_string(),
184 ]);
185
186 fs::remove_file(&plan_path).ok();
187 assert!(result.is_ok(), "{result:?}");
188 }
189
190 #[test]
191 fn inspect_validate_smoke_runs_against_capability_json() {
192 let plan_path = temp_path("validate-smoke-plan", "plexus");
193 let caps_path = temp_path("validate-smoke-caps", "json");
194 fs::write(&plan_path, embedded_fixture_plan_bytes()).expect("write plan bytes");
195
196 let mut caps = EngineCapabilities::full(VersionRange::new(
197 PlanSemver::new(0, 3, 0),
198 PlanSemver::new(0, 3, 0),
199 ));
200 caps.supported_ops.remove(&OpKind::Rerank);
201 caps.supported_exprs.remove(&ExprKind::VectorSimilarity);
202 fs::write(
203 &caps_path,
204 caps.to_json_pretty().expect("serialize capability json"),
205 )
206 .expect("write capability json");
207
208 let result = run_cli(vec![
209 "plexus-inspect".to_string(),
210 "validate".to_string(),
211 "--in".to_string(),
212 plan_path.display().to_string(),
213 "--caps-json".to_string(),
214 caps_path.display().to_string(),
215 "--format".to_string(),
216 "json".to_string(),
217 ]);
218
219 fs::remove_file(&plan_path).ok();
220 fs::remove_file(&caps_path).ok();
221 assert!(result.is_ok(), "{result:?}");
222 }
223}