Skip to main content

cmdhub_shared/
aci.rs

1//! ACI (Agent-Computer Interface) schema types.
2//!
3//! These types define the machine-readable contract that CmdHub returns
4//! to AI Agents for CLI command discovery and execution.
5
6use serde::{Deserialize, Serialize};
7
8/// The hierarchical level of a command node.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum NodeType {
12    /// Root command (e.g., `tar`, `git`)
13    Root,
14    /// Sub-command (e.g., `git commit`, `tar create`)
15    Sub,
16    /// Argument/flag (e.g., `--verbose`, `-f`)
17    Arg,
18}
19
20/// Security risk level for execution gating.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum RiskLevel {
24    /// Read-only operations, no side effects.
25    Safe,
26    /// Local file modifications, network requests.
27    Medium,
28    /// Destructive deletions, privilege escalations.
29    Dangerous,
30}
31
32/// Cross-platform installation instructions.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct InstallInstructions {
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub brew: Option<String>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub apt: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub pacman: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub cargo: Option<String>,
43
44    #[serde(flatten)]
45    #[serde(default)]
46    pub others: std::collections::HashMap<String, String>,
47}
48
49impl InstallInstructions {
50    pub fn get_command(&self, key: &str) -> Option<&String> {
51        match key {
52            "brew" => self.brew.as_ref(),
53            "apt" => self.apt.as_ref(),
54            "pacman" => self.pacman.as_ref(),
55            "cargo" => self.cargo.as_ref(),
56            _ => self.others.get(key),
57        }
58    }
59}
60
61/// The core ACI command contract returned by CmdHub search.
62///
63/// This is the primary data structure that AI Agents consume.
64/// It provides everything needed to discover, understand, and execute a CLI command.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AciCommandContract {
67    /// Unique identifier (e.g., "org.github.mtoyoda.sl")
68    pub app_id: String,
69    /// Base command name (e.g., "sl")
70    pub name: String,
71    /// Materialized path (e.g., "sl.-l", "gh.pr.create")
72    pub cmd_path: String,
73    /// Hierarchical level
74    pub node_type: NodeType,
75    /// Agent-friendly description
76    pub description: String,
77    /// Security risk rating
78    pub risk_level: RiskLevel,
79    /// Ready-to-execute template (e.g., "sl -l")
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub example_template: Option<String>,
82    /// Cross-platform install commands
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub install_instructions: Option<InstallInstructions>,
85}
86
87/// Metadata about the local offline database.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DbMetadata {
90    /// ETag for cache validation
91    pub etag: String,
92    /// Database version string
93    pub version: String,
94    /// Last update timestamp (Unix seconds)
95    pub updated_at: i64,
96    /// Total number of indexed apps
97    pub app_count: u64,
98    /// Total number of indexed commands
99    pub command_count: u64,
100}
101
102/// Update check response from the cloud sync endpoint.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct UpdateManifest {
105    /// Current latest version
106    pub version: String,
107    /// ETag for cache validation
108    pub etag: String,
109    /// CDN download URL for the .zst compressed database
110    pub db_url: String,
111    /// CDN download URL for the Ed25519 signature file
112    pub sig_url: String,
113    /// SHA-256 checksum of the .zst file
114    pub sha256: String,
115}
116
117/// Database record representing the `apps` table row.
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
119pub struct DbApp {
120    pub app_id: String,
121    pub name: String,
122    pub install_instructions: Option<String>,
123}
124
125/// Database record representing the `arguments` table row.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct DbArgument {
128    pub cmd_path: String,
129    pub app_id: String,
130    pub node_name: String,
131    pub node_type: String,
132    pub description: String,
133    pub risk_level: String,
134    pub example_template: Option<String>,
135}
136
137/// Flattened database record representing the JOIN of `arguments` and `apps`.
138///
139/// This provides the exact structure returned by combining a specific
140/// CLI command/argument with its parent app metadata.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct DbAciRecord {
143    pub app_id: String,
144    pub name: String,
145    pub cmd_path: String,
146    pub node_type: String,
147    pub description: String,
148    pub risk_level: String,
149    pub example_template: Option<String>,
150    pub install_instructions: Option<String>,
151}
152
153impl AciCommandContract {
154    /// Extracts the node name from the cmd_path (the last component after '.')
155    pub fn node_name(&self) -> &str {
156        self.cmd_path.split('.').next_back().unwrap_or(&self.name)
157    }
158
159    /// Converts this contract into offline SQLite database records.
160    pub fn to_db_records(&self) -> Result<(DbApp, DbArgument), crate::error::CmdHubError> {
161        let install_instructions = if let Some(ref inst) = self.install_instructions {
162            Some(serde_json::to_string(inst)?)
163        } else {
164            None
165        };
166
167        let app = DbApp {
168            app_id: self.app_id.clone(),
169            name: self.name.clone(),
170            install_instructions,
171        };
172
173        let node_type_str = match self.node_type {
174            NodeType::Root => "root",
175            NodeType::Sub => "sub",
176            NodeType::Arg => "arg",
177        };
178
179        let risk_level_str = match self.risk_level {
180            RiskLevel::Safe => "safe",
181            RiskLevel::Medium => "medium",
182            RiskLevel::Dangerous => "dangerous",
183        };
184
185        let argument = DbArgument {
186            cmd_path: self.cmd_path.clone(),
187            app_id: self.app_id.clone(),
188            node_name: self.node_name().to_string(),
189            node_type: node_type_str.to_string(),
190            description: self.description.clone(),
191            risk_level: risk_level_str.to_string(),
192            example_template: self.example_template.clone(),
193        };
194
195        Ok((app, argument))
196    }
197}
198
199impl TryFrom<DbAciRecord> for AciCommandContract {
200    type Error = crate::error::CmdHubError;
201
202    fn try_from(record: DbAciRecord) -> Result<Self, Self::Error> {
203        let node_type = match record.node_type.as_str() {
204            "root" => NodeType::Root,
205            "sub" => NodeType::Sub,
206            "arg" => NodeType::Arg,
207            other => {
208                return Err(crate::error::CmdHubError::Validation(format!(
209                    "Invalid node_type in database: '{}'",
210                    other
211                )))
212            }
213        };
214
215        let risk_level = match record.risk_level.as_str() {
216            "safe" => RiskLevel::Safe,
217            "medium" => RiskLevel::Medium,
218            "dangerous" => RiskLevel::Dangerous,
219            other => {
220                return Err(crate::error::CmdHubError::Validation(format!(
221                    "Invalid risk_level in database: '{}'",
222                    other
223                )))
224            }
225        };
226
227        let install_instructions = if let Some(ref inst_str) = record.install_instructions {
228            if inst_str.trim().is_empty() {
229                None
230            } else {
231                Some(serde_json::from_str(inst_str).map_err(|e| {
232                    crate::error::CmdHubError::Validation(format!(
233                        "Failed to parse install_instructions JSON: {}",
234                        e
235                    ))
236                })?)
237            }
238        } else {
239            None
240        };
241
242        Ok(AciCommandContract {
243            app_id: record.app_id,
244            name: record.name,
245            cmd_path: record.cmd_path,
246            node_type,
247            description: record.description,
248            risk_level,
249            example_template: record.example_template,
250            install_instructions,
251        })
252    }
253}
254
255/// SQL statement to create the physical table `apps`.
256pub const CREATE_APPS_TABLE: &str = r#"
257CREATE TABLE IF NOT EXISTS apps (
258    app_id TEXT PRIMARY KEY,
259    name TEXT NOT NULL,
260    install_instructions TEXT
261);
262"#;
263
264/// SQL statement to create the physical table `arguments`.
265pub const CREATE_ARGUMENTS_TABLE: &str = r#"
266CREATE TABLE IF NOT EXISTS arguments (
267    cmd_path TEXT PRIMARY KEY,
268    app_id TEXT NOT NULL,
269    node_name TEXT NOT NULL,
270    node_type TEXT NOT NULL,
271    description TEXT NOT NULL,
272    risk_level TEXT NOT NULL,
273    example_template TEXT,
274    FOREIGN KEY(app_id) REFERENCES apps(app_id) ON DELETE CASCADE
275);
276"#;
277
278/// SQL statement to create the FTS5 virtual table `apps_fts`.
279pub const CREATE_APPS_FTS_TABLE: &str = r#"
280CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
281    cmd_path UNINDEXED,
282    name,
283    capabilities
284);
285"#;
286
287/// SQL statement to create the sqlite-vec virtual table `commands_vec`.
288pub const CREATE_COMMANDS_VEC_TABLE: &str = r#"
289CREATE VIRTUAL TABLE IF NOT EXISTS commands_vec USING vec0(
290    cmd_path TEXT PRIMARY KEY,
291    embedding float[512]
292);
293"#;
294
295/// The Reciprocal Rank Fusion (RRF) hybrid search query combining FTS5 and sqlite-vec.
296pub const RRF_QUERY: &str = r#"
297WITH fts_rank AS (
298    SELECT cmd_path, row_number() OVER (ORDER BY bm25(apps_fts) ASC) as fts_pos
299    FROM apps_fts WHERE apps_fts MATCH :query
300),
301vec_rank AS (
302    SELECT cmd_path, row_number() OVER (ORDER BY vec_distance_cosine(embedding, :query_vector) ASC) as vec_pos
303    FROM commands_vec
304)
305SELECT
306    arg.cmd_path, arg.node_name, arg.description, arg.risk_level, arg.example_template,
307    COALESCE(1.0 / (60.0 + fts.fts_pos), 0.0) + COALESCE(1.0 / (60.0 + vec.vec_pos), 0.0) as rrf_score
308FROM arguments arg
309LEFT JOIN fts_rank fts ON arg.cmd_path = fts.cmd_path
310LEFT JOIN vec_rank vec ON arg.cmd_path = vec.cmd_path
311WHERE fts.cmd_path IS NOT NULL OR vec.cmd_path IS NOT NULL
312ORDER BY rrf_score DESC
313LIMIT :limit_num;
314"#;
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_aci_serialization_roundtrip() {
322        let contract = AciCommandContract {
323            app_id: "org.github.mtoyoda.sl".to_string(),
324            name: "sl".to_string(),
325            cmd_path: "sl.-l".to_string(),
326            node_type: NodeType::Arg,
327            description: "Display a train moving from left to right".to_string(),
328            risk_level: RiskLevel::Safe,
329            example_template: Some("sl -l".to_string()),
330            install_instructions: None,
331        };
332
333        let json = serde_json::to_string(&contract).unwrap();
334        let deserialized: AciCommandContract = serde_json::from_str(&json).unwrap();
335        assert_eq!(contract.app_id, deserialized.app_id);
336        assert_eq!(contract.cmd_path, deserialized.cmd_path);
337        assert_eq!(contract.risk_level, deserialized.risk_level);
338    }
339
340    #[test]
341    fn test_risk_level_json_values() {
342        assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), "\"safe\"");
343        assert_eq!(
344            serde_json::to_string(&RiskLevel::Dangerous).unwrap(),
345            "\"dangerous\""
346        );
347    }
348
349    #[test]
350    fn test_db_conversions() {
351        let contract = AciCommandContract {
352            app_id: "org.github.mtoyoda.sl".to_string(),
353            name: "sl".to_string(),
354            cmd_path: "sl.-l".to_string(),
355            node_type: NodeType::Arg,
356            description: "Display a train moving from left to right".to_string(),
357            risk_level: RiskLevel::Safe,
358            example_template: Some("sl -l".to_string()),
359            install_instructions: Some(InstallInstructions {
360                brew: Some("brew install sl".to_string()),
361                apt: Some("sudo apt install sl".to_string()),
362                pacman: None,
363                cargo: None,
364                ..Default::default()
365            }),
366        };
367
368        // Test node_name extraction
369        assert_eq!(contract.node_name(), "-l");
370
371        // Test converting to DB records
372        let (db_app, db_arg) = contract.to_db_records().unwrap();
373        assert_eq!(db_app.app_id, "org.github.mtoyoda.sl");
374        assert_eq!(db_app.name, "sl");
375        assert!(db_app
376            .install_instructions
377            .as_ref()
378            .unwrap()
379            .contains("brew install sl"));
380
381        assert_eq!(db_arg.cmd_path, "sl.-l");
382        assert_eq!(db_arg.app_id, "org.github.mtoyoda.sl");
383        assert_eq!(db_arg.node_name, "-l");
384        assert_eq!(db_arg.node_type, "arg");
385        assert_eq!(db_arg.risk_level, "safe");
386        assert_eq!(db_arg.example_template, Some("sl -l".to_string()));
387
388        // Test reconstruction from DbAciRecord
389        let db_record = DbAciRecord {
390            app_id: db_app.app_id,
391            name: db_app.name,
392            cmd_path: db_arg.cmd_path,
393            node_type: db_arg.node_type,
394            description: db_arg.description,
395            risk_level: db_arg.risk_level,
396            example_template: db_arg.example_template,
397            install_instructions: db_app.install_instructions,
398        };
399
400        let reconstructed = AciCommandContract::try_from(db_record).unwrap();
401        assert_eq!(reconstructed.app_id, contract.app_id);
402        assert_eq!(reconstructed.cmd_path, contract.cmd_path);
403        assert_eq!(reconstructed.node_type, contract.node_type);
404        assert_eq!(reconstructed.risk_level, contract.risk_level);
405        assert_eq!(
406            reconstructed.install_instructions.as_ref().unwrap().brew,
407            Some("brew install sl".to_string())
408        );
409    }
410
411    #[test]
412    fn test_install_instructions_flattened_others() {
413        let json_data = r#"{
414            "brew": "brew install git",
415            "dnf": "dnf install -y git",
416            "apk": "apk add git"
417        }"#;
418        let inst: InstallInstructions = serde_json::from_str(json_data).unwrap();
419        assert_eq!(inst.brew.as_deref(), Some("brew install git"));
420        assert_eq!(
421            inst.get_command("brew").map(|s| s.as_str()),
422            Some("brew install git")
423        );
424        assert_eq!(
425            inst.get_command("dnf").map(|s| s.as_str()),
426            Some("dnf install -y git")
427        );
428        assert_eq!(
429            inst.get_command("apk").map(|s| s.as_str()),
430            Some("apk add git")
431        );
432        assert_eq!(inst.get_command("pacman"), None);
433    }
434}