Skip to main content

presolve_cli/
command_framework.rs

1//! L9-B command metadata and explicit project-envelope loading.
2//!
3//! The loader reads only the exact configuration path passed by its caller. It
4//! does not enumerate source roots, discover a workspace, or compile sources.
5#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
6
7use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use presolve_compiler::platform::WorkspaceConfiguration;
12
13use crate::decode_cli_workspace_configuration_bytes_v1;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum CliExitCodeV1 {
17    Success = 0,
18    CompilationFailure = 1,
19    ConfigurationError = 2,
20    WorkspaceError = 3,
21    CompilerInternalError = 4,
22    CacheError = 5,
23    ToolingError = 6,
24    UnexpectedPlatformError = 7,
25}
26impl CliExitCodeV1 {
27    #[must_use]
28    pub const fn as_i32(self) -> i32 {
29        self as i32
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum CliCommandV1 {
35    Create,
36    Dev,
37    Build,
38    Watch,
39    Check,
40    Clean,
41    Explain,
42    Inspect,
43    Graph,
44    Trace,
45    Profile,
46    Benchmark,
47    Doctor,
48    Cache,
49    Workspace,
50    Version,
51}
52impl CliCommandV1 {
53    #[must_use]
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::Create => "create",
57            Self::Dev => "dev",
58            Self::Build => "build",
59            Self::Watch => "watch",
60            Self::Check => "check",
61            Self::Clean => "clean",
62            Self::Explain => "explain",
63            Self::Inspect => "inspect",
64            Self::Graph => "graph",
65            Self::Trace => "trace",
66            Self::Profile => "profile",
67            Self::Benchmark => "benchmark",
68            Self::Doctor => "doctor",
69            Self::Cache => "cache",
70            Self::Workspace => "workspace",
71            Self::Version => "version",
72        }
73    }
74}
75
76#[must_use]
77pub fn parse_cli_command_v1(value: &str) -> Option<CliCommandV1> {
78    match value {
79        "create" => Some(CliCommandV1::Create),
80        "dev" => Some(CliCommandV1::Dev),
81        "build" => Some(CliCommandV1::Build),
82        "watch" => Some(CliCommandV1::Watch),
83        "check" => Some(CliCommandV1::Check),
84        "clean" => Some(CliCommandV1::Clean),
85        "explain" => Some(CliCommandV1::Explain),
86        "inspect" => Some(CliCommandV1::Inspect),
87        "graph" => Some(CliCommandV1::Graph),
88        "trace" => Some(CliCommandV1::Trace),
89        "profile" => Some(CliCommandV1::Profile),
90        "benchmark" => Some(CliCommandV1::Benchmark),
91        "doctor" => Some(CliCommandV1::Doctor),
92        "cache" => Some(CliCommandV1::Cache),
93        "workspace" => Some(CliCommandV1::Workspace),
94        "version" => Some(CliCommandV1::Version),
95        _ => None,
96    }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct CliProjectEnvelopeV1 {
101    pub project_root: PathBuf,
102    pub configuration_path: PathBuf,
103    pub configuration: WorkspaceConfiguration,
104}
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct CliProjectEnvelopeErrorV1 {
107    pub code: &'static str,
108    pub message: String,
109}
110impl fmt::Display for CliProjectEnvelopeErrorV1 {
111    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(output, "{}: {}", self.code, self.message)
113    }
114}
115impl std::error::Error for CliProjectEnvelopeErrorV1 {}
116
117/// Loads one caller-named public configuration document. This is intentionally
118/// not configuration discovery: neither `project_root` nor `source_roots` are
119/// opened, scanned, globbed, or resolved here.
120pub fn load_explicit_project_envelope_v1(
121    project_root: &Path,
122    configuration_path: &Path,
123) -> Result<CliProjectEnvelopeV1, CliProjectEnvelopeErrorV1> {
124    let bytes = fs::read(configuration_path).map_err(|error| CliProjectEnvelopeErrorV1 {
125        code: "L9P001_CONFIGURATION_READ_FAILED",
126        message: format!("failed to read {}: {error}", configuration_path.display()),
127    })?;
128    let configuration = decode_cli_workspace_configuration_bytes_v1(&bytes).map_err(|error| {
129        CliProjectEnvelopeErrorV1 {
130            code: "L9P002_CONFIGURATION_DECODE_FAILED",
131            message: error.to_string(),
132        }
133    })?;
134    Ok(CliProjectEnvelopeV1 {
135        project_root: project_root.into(),
136        configuration_path: configuration_path.into(),
137        configuration,
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    #[test]
145    fn l9b_command_names_and_exit_codes_are_stable() {
146        assert_eq!(parse_cli_command_v1("version"), Some(CliCommandV1::Version));
147        assert_eq!(parse_cli_command_v1("unknown"), None);
148        assert_eq!(CliExitCodeV1::ConfigurationError.as_i32(), 2);
149        assert_eq!(CliCommandV1::Workspace.as_str(), "workspace");
150    }
151    #[test]
152    fn l9b_loader_reads_only_the_explicit_configuration_document() {
153        let root = std::env::temp_dir().join("presolve-l9b-envelope");
154        let _ = fs::remove_dir_all(&root);
155        fs::create_dir_all(&root).unwrap();
156        let config = root.join("chosen.json");
157        fs::write(
158            &config,
159            include_bytes!("../fixtures/configuration/minimum-cli-v1.json"),
160        )
161        .unwrap();
162        let envelope = load_explicit_project_envelope_v1(&root, &config).unwrap();
163        assert_eq!(envelope.configuration, WorkspaceConfiguration::default());
164        assert_eq!(envelope.configuration_path, config);
165        fs::remove_dir_all(root).unwrap();
166    }
167}