Skip to main content

plexus_devtools/
app.rs

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}