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    Migrate,
37    Dev,
38    Build,
39    Watch,
40    Check,
41    Clean,
42    Explain,
43    Inspect,
44    Graph,
45    Trace,
46    Profile,
47    Benchmark,
48    Doctor,
49    Cache,
50    Workspace,
51    Version,
52}
53impl CliCommandV1 {
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::Create => "create",
58            Self::Migrate => "migrate",
59            Self::Dev => "dev",
60            Self::Build => "build",
61            Self::Watch => "watch",
62            Self::Check => "check",
63            Self::Clean => "clean",
64            Self::Explain => "explain",
65            Self::Inspect => "inspect",
66            Self::Graph => "graph",
67            Self::Trace => "trace",
68            Self::Profile => "profile",
69            Self::Benchmark => "benchmark",
70            Self::Doctor => "doctor",
71            Self::Cache => "cache",
72            Self::Workspace => "workspace",
73            Self::Version => "version",
74        }
75    }
76}
77
78#[must_use]
79pub fn parse_cli_command_v1(value: &str) -> Option<CliCommandV1> {
80    match value {
81        "create" => Some(CliCommandV1::Create),
82        "migrate" => Some(CliCommandV1::Migrate),
83        "dev" => Some(CliCommandV1::Dev),
84        "build" => Some(CliCommandV1::Build),
85        "watch" => Some(CliCommandV1::Watch),
86        "check" => Some(CliCommandV1::Check),
87        "clean" => Some(CliCommandV1::Clean),
88        "explain" => Some(CliCommandV1::Explain),
89        "inspect" => Some(CliCommandV1::Inspect),
90        "graph" => Some(CliCommandV1::Graph),
91        "trace" => Some(CliCommandV1::Trace),
92        "profile" => Some(CliCommandV1::Profile),
93        "benchmark" => Some(CliCommandV1::Benchmark),
94        "doctor" => Some(CliCommandV1::Doctor),
95        "cache" => Some(CliCommandV1::Cache),
96        "workspace" => Some(CliCommandV1::Workspace),
97        "version" => Some(CliCommandV1::Version),
98        _ => None,
99    }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct CliProjectEnvelopeV1 {
104    pub project_root: PathBuf,
105    pub configuration_path: PathBuf,
106    pub configuration: WorkspaceConfiguration,
107}
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct CliProjectEnvelopeErrorV1 {
110    pub code: &'static str,
111    pub message: String,
112}
113impl fmt::Display for CliProjectEnvelopeErrorV1 {
114    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
115        write!(output, "{}: {}", self.code, self.message)
116    }
117}
118impl std::error::Error for CliProjectEnvelopeErrorV1 {}
119
120/// Loads one caller-named public configuration document. This is intentionally
121/// not configuration discovery: neither `project_root` nor `source_roots` are
122/// opened, scanned, globbed, or resolved here.
123pub fn load_explicit_project_envelope_v1(
124    project_root: &Path,
125    configuration_path: &Path,
126) -> Result<CliProjectEnvelopeV1, CliProjectEnvelopeErrorV1> {
127    let bytes = fs::read(configuration_path).map_err(|error| CliProjectEnvelopeErrorV1 {
128        code: "L9P001_CONFIGURATION_READ_FAILED",
129        message: format!("failed to read {}: {error}", configuration_path.display()),
130    })?;
131    let configuration = decode_cli_workspace_configuration_bytes_v1(&bytes).map_err(|error| {
132        CliProjectEnvelopeErrorV1 {
133            code: "L9P002_CONFIGURATION_DECODE_FAILED",
134            message: error.to_string(),
135        }
136    })?;
137    Ok(CliProjectEnvelopeV1 {
138        project_root: project_root.into(),
139        configuration_path: configuration_path.into(),
140        configuration,
141    })
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    #[test]
148    fn l9b_command_names_and_exit_codes_are_stable() {
149        assert_eq!(parse_cli_command_v1("version"), Some(CliCommandV1::Version));
150        assert_eq!(parse_cli_command_v1("unknown"), None);
151        assert_eq!(CliExitCodeV1::ConfigurationError.as_i32(), 2);
152        assert_eq!(CliCommandV1::Workspace.as_str(), "workspace");
153    }
154    #[test]
155    fn l9b_loader_reads_only_the_explicit_configuration_document() {
156        let root = std::env::temp_dir().join("presolve-l9b-envelope");
157        let _ = fs::remove_dir_all(&root);
158        fs::create_dir_all(&root).unwrap();
159        let config = root.join("chosen.json");
160        fs::write(
161            &config,
162            include_bytes!("../fixtures/configuration/minimum-cli-v1.json"),
163        )
164        .unwrap();
165        let envelope = load_explicit_project_envelope_v1(&root, &config).unwrap();
166        assert_eq!(envelope.configuration, WorkspaceConfiguration::default());
167        assert_eq!(envelope.configuration_path, config);
168        fs::remove_dir_all(root).unwrap();
169    }
170}