Skip to main content

soma_cli/
provider_command.rs

1//! `soma providers validate|inspect|test` — dispatches through the *live,
2//! loaded application provider catalog; executes handlers.
3//!
4//! Distinct from the `providers` module (`soma providers list|lint|status`),
5//! which is non-executing filesystem inspection that never touches the
6//! registry.
7
8use anyhow::{Result, anyhow};
9use serde_json::{Value, json};
10use soma_application::{ExecuteActionRequest, SomaApplication};
11use std::path::PathBuf;
12
13use crate::{Command, cli_execution_context};
14
15#[derive(Debug, PartialEq, Eq)]
16pub enum ProviderCommand {
17    Validate,
18    Inspect,
19    Test {
20        action: String,
21        json: Value,
22    },
23    /// Non-executing: lists drop-in provider files without loading the registry.
24    List {
25        dir: Option<PathBuf>,
26        json: bool,
27    },
28    /// Non-executing: lints drop-in provider files without loading the registry.
29    Lint {
30        dir: Option<PathBuf>,
31        json: bool,
32    },
33    /// Non-executing: summarizes drop-in provider files without loading the registry.
34    Status {
35        dir: Option<PathBuf>,
36        json: bool,
37    },
38    Graduate {
39        source: PathBuf,
40        workspace: PathBuf,
41        fixtures: Option<PathBuf>,
42    },
43    BuildComponent {
44        workspace: PathBuf,
45        component: Option<PathBuf>,
46    },
47    VerifyComponent {
48        component: PathBuf,
49    },
50    Compare {
51        workspace: PathBuf,
52        component: Option<PathBuf>,
53        fixtures: PathBuf,
54    },
55    GraduationStatus {
56        workspace: PathBuf,
57    },
58    Componentize {
59        operation: String,
60        workspace: PathBuf,
61        wheelhouse: Option<PathBuf>,
62    },
63    Activate {
64        workspace: PathBuf,
65        confirmed: bool,
66    },
67    Rollback {
68        workspace: PathBuf,
69        confirmed: bool,
70    },
71}
72
73impl ProviderCommand {
74    /// The three non-executing variants never touch the live registry — they
75    /// only parse manifests on disk, so `run()` short-circuits before any
76    /// client/service/registry construction for these.
77    pub fn is_non_executing(&self) -> bool {
78        matches!(
79            self,
80            ProviderCommand::List { .. }
81                | ProviderCommand::Lint { .. }
82                | ProviderCommand::Status { .. }
83        )
84    }
85}
86
87pub(crate) async fn run_provider_management_command(
88    command: &ProviderCommand,
89    application: &SomaApplication,
90    destructive_confirmed: bool,
91) -> Result<Value> {
92    match command {
93        ProviderCommand::Validate => Ok(application.provider_validation_summary()),
94        ProviderCommand::Inspect => Ok(application.provider_inspection_report()),
95        ProviderCommand::Test { action, json } => {
96            let provider = application.provider_for_action(action);
97            match application
98                .execute_action(
99                    ExecuteActionRequest {
100                        action: action.clone(),
101                        params: json.clone(),
102                    },
103                    cli_execution_context(destructive_confirmed),
104                )
105                .await
106            {
107                Ok(output) => Ok(json!({
108                    "schema_version": 1,
109                    "ok": true,
110                    "action": action,
111                    "provider": provider,
112                    "result": output.output,
113                    "request_id": output.request_id,
114                    "progress": output.progress,
115                })),
116                Err(error) => Err(anyhow!(error)),
117            }
118        }
119        ProviderCommand::Graduate {
120            source,
121            workspace,
122            fixtures,
123        } => {
124            graduation_action(
125                application,
126                "graduate",
127                workspace,
128                Some(source),
129                None,
130                fixtures.as_ref(),
131                destructive_confirmed,
132            )
133            .await
134        }
135        ProviderCommand::BuildComponent {
136            workspace,
137            component,
138        } => {
139            graduation_action(
140                application,
141                "build-component",
142                workspace,
143                None,
144                component.as_ref(),
145                None,
146                destructive_confirmed,
147            )
148            .await
149        }
150        ProviderCommand::VerifyComponent { component } => {
151            graduation_action(
152                application,
153                "verify-component",
154                component
155                    .parent()
156                    .unwrap_or_else(|| std::path::Path::new(".")),
157                None,
158                Some(component),
159                None,
160                destructive_confirmed,
161            )
162            .await
163        }
164        ProviderCommand::Compare {
165            workspace,
166            component,
167            fixtures,
168        } => {
169            graduation_action(
170                application,
171                "compare",
172                workspace,
173                None,
174                component.as_ref(),
175                Some(fixtures),
176                destructive_confirmed,
177            )
178            .await
179        }
180        ProviderCommand::Componentize {
181            operation,
182            workspace,
183            wheelhouse,
184        } => {
185            componentize_action(
186                application,
187                operation,
188                workspace,
189                wheelhouse.as_ref(),
190                destructive_confirmed,
191            )
192            .await
193        }
194        ProviderCommand::GraduationStatus { workspace } => application
195            .execute_action(
196                ExecuteActionRequest {
197                    action: "python_graduation_status".to_owned(),
198                    params: json!({"workspace": workspace}),
199                },
200                cli_execution_context(destructive_confirmed),
201            )
202            .await
203            .map(|response| response.into_surface_value())
204            .map_err(anyhow::Error::from),
205        ProviderCommand::Activate { workspace, .. } => {
206            graduation_action(
207                application,
208                "activate",
209                workspace,
210                None,
211                None,
212                None,
213                destructive_confirmed,
214            )
215            .await
216        }
217        ProviderCommand::Rollback { workspace, .. } => {
218            graduation_action(
219                application,
220                "rollback",
221                workspace,
222                None,
223                None,
224                None,
225                destructive_confirmed,
226            )
227            .await
228        }
229        ProviderCommand::List { .. }
230        | ProviderCommand::Lint { .. }
231        | ProviderCommand::Status { .. } => {
232            unreachable!("non-executing provider commands are handled before registry construction")
233        }
234    }
235}
236
237async fn componentize_action(
238    application: &SomaApplication,
239    operation: &str,
240    workspace: &std::path::Path,
241    wheelhouse: Option<&std::path::PathBuf>,
242    confirmed: bool,
243) -> Result<Value> {
244    let mut params = json!({
245        "operation": operation,
246        "workspace": workspace,
247        "confirm": confirmed,
248    });
249    if let Some(wheelhouse) = wheelhouse {
250        params
251            .as_object_mut()
252            .expect("object")
253            .insert("wheelhouse".to_owned(), json!(wheelhouse));
254    }
255    application
256        .execute_action(
257            ExecuteActionRequest {
258                action: "python_graduation_apply".to_owned(),
259                params,
260            },
261            cli_execution_context(confirmed),
262        )
263        .await
264        .map(|response| response.into_surface_value())
265        .map_err(anyhow::Error::from)
266}
267
268async fn graduation_action(
269    application: &SomaApplication,
270    operation: &str,
271    workspace: &std::path::Path,
272    source: Option<&std::path::PathBuf>,
273    component: Option<&std::path::PathBuf>,
274    fixtures: Option<&std::path::PathBuf>,
275    confirmed: bool,
276) -> Result<Value> {
277    let mut params = json!({
278        "operation": operation,
279        "workspace": workspace,
280        "confirm": confirmed,
281    });
282    let object = params.as_object_mut().expect("object");
283    if let Some(source) = source {
284        object.insert("source".to_owned(), json!(source));
285    }
286    if let Some(component) = component {
287        object.insert("component".to_owned(), json!(component));
288    }
289    if let Some(fixtures) = fixtures {
290        object.insert("fixtures".to_owned(), json!(fixtures));
291    }
292    application
293        .execute_action(
294            ExecuteActionRequest {
295                action: "python_graduation_apply".to_owned(),
296                params,
297            },
298            cli_execution_context(confirmed),
299        )
300        .await
301        .map(|response| response.into_surface_value())
302        .map_err(anyhow::Error::from)
303}
304
305pub(crate) fn parse_providers_command(args: &[String]) -> Result<Command> {
306    match args {
307        [action] if action == "validate" => Ok(Command::Providers(ProviderCommand::Validate)),
308        [action] if action == "inspect" => Ok(Command::Providers(ProviderCommand::Inspect)),
309        [action, provider_action] if action == "test" => {
310            Ok(Command::Providers(ProviderCommand::Test {
311                action: provider_action.clone(),
312                json: json!({}),
313            }))
314        }
315        [action, provider_action, flag, payload] if action == "test" && flag == "--json" => {
316            Ok(Command::Providers(ProviderCommand::Test {
317                action: provider_action.clone(),
318                json: serde_json::from_str(payload).map_err(|error| {
319                    anyhow!("providers test {provider_action} --json must be valid JSON: {error}")
320                })?,
321            }))
322        }
323        [action, rest @ ..] if action == "list" || action == "lint" || action == "status" => {
324            let (dir, json) = parse_providers_dir_flags(action, rest)?;
325            Ok(Command::Providers(match action.as_str() {
326                "list" => ProviderCommand::List { dir, json },
327                "lint" => ProviderCommand::Lint { dir, json },
328                _ => ProviderCommand::Status { dir, json },
329            }))
330        }
331        [action, rest @ ..]
332            if matches!(
333                action.as_str(),
334                "graduate"
335                    | "build-component"
336                    | "verify-component"
337                    | "compare"
338                    | "graduation-status"
339                    | "componentize-scan"
340                    | "componentize-bindings"
341                    | "componentize-build"
342                    | "componentize-validate"
343                    | "activate"
344                    | "rollback"
345            ) =>
346        {
347            Ok(Command::Providers(parse_graduation_command(action, rest)?))
348        }
349        [] => Err(anyhow!(
350            "providers requires list, lint, status, validate, inspect, test, graduate, \
351             build-component, verify-component, compare, componentize-scan, componentize-bindings, \
352             componentize-build, componentize-validate, graduation-status, activate, or rollback"
353        )),
354        [unexpected, ..] => Err(anyhow!("providers does not accept argument `{unexpected}`")),
355    }
356}
357
358fn parse_graduation_command(command: &str, args: &[String]) -> Result<ProviderCommand> {
359    let required = |flag: &str| -> Result<PathBuf> {
360        args.windows(2)
361            .find(|pair| pair[0] == flag)
362            .map(|pair| PathBuf::from(&pair[1]))
363            .ok_or_else(|| anyhow!("providers {command} {flag} requires a value"))
364    };
365    let expected = match command {
366        "graduate" => &["--source", "--workspace", "--fixtures"][..],
367        "build-component" => &["--workspace", "--component"][..],
368        "verify-component" => &["--component"][..],
369        "compare" => &["--workspace", "--component", "--fixtures"][..],
370        "graduation-status" => &["--workspace"][..],
371        "componentize-scan" => &["--workspace", "--wheelhouse"][..],
372        "componentize-bindings" | "componentize-build" | "componentize-validate" => {
373            &["--workspace"][..]
374        }
375        "activate" | "rollback" => &["--workspace", "--confirm"][..],
376        _ => unreachable!(),
377    };
378    let required_count = match command {
379        "graduate" | "compare" => 2,
380        "build-component" | "componentize-scan" => 1,
381        _ => expected.len(),
382    };
383    if !args.len().is_multiple_of(2)
384        || args.len() < required_count * 2
385        || args.len() > expected.len() * 2
386        || args
387            .chunks_exact(2)
388            .any(|pair| !expected.contains(&pair[0].as_str()) || pair[1].starts_with("--"))
389        || args
390            .chunks_exact(2)
391            .map(|pair| pair[0].as_str())
392            .collect::<std::collections::BTreeSet<_>>()
393            .len()
394            != args.len() / 2
395    {
396        return Err(anyhow!(
397            "providers {command} expects {}",
398            expected
399                .iter()
400                .map(|flag| format!("{flag} PATH"))
401                .collect::<Vec<_>>()
402                .join(" ")
403        ));
404    }
405    match command {
406        "graduate" => Ok(ProviderCommand::Graduate {
407            source: required("--source")?,
408            workspace: required("--workspace")?,
409            fixtures: args
410                .windows(2)
411                .find(|pair| pair[0] == "--fixtures")
412                .map(|pair| PathBuf::from(&pair[1])),
413        }),
414        "build-component" => Ok(ProviderCommand::BuildComponent {
415            workspace: required("--workspace")?,
416            component: args
417                .windows(2)
418                .find(|pair| pair[0] == "--component")
419                .map(|pair| PathBuf::from(&pair[1])),
420        }),
421        "verify-component" => Ok(ProviderCommand::VerifyComponent {
422            component: required("--component")?,
423        }),
424        "compare" => Ok(ProviderCommand::Compare {
425            workspace: required("--workspace")?,
426            component: args
427                .windows(2)
428                .find(|pair| pair[0] == "--component")
429                .map(|pair| PathBuf::from(&pair[1])),
430            fixtures: required("--fixtures")?,
431        }),
432        "graduation-status" => Ok(ProviderCommand::GraduationStatus {
433            workspace: required("--workspace")?,
434        }),
435        "componentize-scan"
436        | "componentize-bindings"
437        | "componentize-build"
438        | "componentize-validate" => Ok(ProviderCommand::Componentize {
439            operation: command.to_owned(),
440            workspace: required("--workspace")?,
441            wheelhouse: args
442                .windows(2)
443                .find(|pair| pair[0] == "--wheelhouse")
444                .map(|pair| PathBuf::from(&pair[1])),
445        }),
446        "activate" => Ok(ProviderCommand::Activate {
447            workspace: required("--workspace")?,
448            confirmed: args
449                .windows(2)
450                .find(|pair| pair[0] == "--confirm")
451                .is_some_and(|pair| pair[1] == "true"),
452        }),
453        "rollback" => Ok(ProviderCommand::Rollback {
454            workspace: required("--workspace")?,
455            confirmed: args
456                .windows(2)
457                .find(|pair| pair[0] == "--confirm")
458                .is_some_and(|pair| pair[1] == "true"),
459        }),
460        _ => unreachable!(),
461    }
462}
463
464fn parse_providers_dir_flags(command: &str, args: &[String]) -> Result<(Option<PathBuf>, bool)> {
465    let mut dir = None;
466    let mut json = false;
467    let mut index = 0;
468    while index < args.len() {
469        match args[index].as_str() {
470            "--dir" => {
471                let value = args
472                    .get(index + 1)
473                    .ok_or_else(|| anyhow!("providers {command} --dir requires a value"))?;
474                if value.starts_with("--") {
475                    return Err(anyhow!("providers {command} --dir requires a value"));
476                }
477                dir = Some(PathBuf::from(value));
478                index += 2;
479            }
480            "--json" => {
481                json = true;
482                index += 1;
483            }
484            unknown => {
485                return Err(anyhow!(
486                    "providers {command} does not accept argument `{unknown}`"
487                ));
488            }
489        }
490    }
491    Ok((dir, json))
492}
493
494#[cfg(test)]
495#[path = "provider_command_tests.rs"]
496mod tests;