Skip to main content

bijux_cli/contracts/
command.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Canonical command namespace segment.
5#[derive(
6    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
7)]
8pub struct Namespace(pub String);
9
10impl Namespace {
11    /// Build a normalized namespace.
12    pub fn new(raw: &str) -> Result<Self, String> {
13        let normalized = Self::normalize(raw);
14        if normalized.is_empty() {
15            return Err("namespace cannot be empty".to_string());
16        }
17        if !normalized.chars().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
18        {
19            return Err("namespace must be lowercase kebab-case".to_string());
20        }
21        if normalized.starts_with('-') || normalized.ends_with('-') || normalized.contains("--") {
22            return Err(
23                "namespace cannot start/end with '-' or contain consecutive '-'".to_string()
24            );
25        }
26        Ok(Self(normalized))
27    }
28
29    /// Normalize namespace input to lowercase kebab-case candidate.
30    #[must_use]
31    pub fn normalize(raw: &str) -> String {
32        raw.trim()
33            .to_ascii_lowercase()
34            .replace(['_', ' ', '/'], "-")
35            .split('-')
36            .filter(|segment| !segment.is_empty())
37            .collect::<Vec<_>>()
38            .join("-")
39    }
40
41    /// Borrow normalized namespace string.
42    #[must_use]
43    pub fn as_str(&self) -> &str {
44        &self.0
45    }
46}
47
48/// Canonical command path composed from namespace segments.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
50pub struct CommandPath {
51    /// Ordered namespace segments from root to leaf.
52    pub segments: Vec<Namespace>,
53}
54
55impl CommandPath {
56    /// Build a command path with normalized segments.
57    pub fn new(raw_segments: &[&str]) -> Result<Self, String> {
58        let mut segments = Vec::with_capacity(raw_segments.len());
59        for value in raw_segments {
60            segments.push(Namespace::new(value)?);
61        }
62        if segments.is_empty() {
63            return Err("command path requires at least one segment".to_string());
64        }
65        Ok(Self { segments })
66    }
67
68    /// Parse and normalize command path from a whitespace- or slash-delimited string.
69    pub fn parse(raw: &str) -> Result<Self, String> {
70        let input = raw.trim();
71        if input.is_empty() {
72            return Err("command path cannot be empty".to_string());
73        }
74        let segments: Vec<&str> = input
75            .split(|ch: char| ch.is_ascii_whitespace() || ch == '/')
76            .filter(|segment| !segment.is_empty())
77            .collect();
78        Self::new(&segments)
79    }
80
81    /// Join path segments as a single command string.
82    #[must_use]
83    pub fn to_command_string(&self) -> String {
84        self.segments.iter().map(Namespace::as_str).collect::<Vec<_>>().join(" ")
85    }
86}
87
88/// Stable command metadata used by help and inspect APIs.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90pub struct CommandMetadata {
91    /// Canonical command path.
92    pub path: CommandPath,
93    /// Human-readable summary.
94    pub summary: String,
95    /// Whether the command is hidden from help.
96    pub hidden: bool,
97    /// Stable aliases.
98    pub aliases: Vec<CommandPath>,
99}
100
101/// Stable namespace metadata used by route-tree introspection.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
103pub struct NamespaceMetadata {
104    /// Namespace identifier.
105    pub name: Namespace,
106    /// Whether this namespace is reserved.
107    pub reserved: bool,
108    /// Owning product or component.
109    pub owner: String,
110}