1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum NodeType {
12 Root,
14 Sub,
16 Arg,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum RiskLevel {
24 Safe,
26 Medium,
28 Dangerous,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
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 #[serde(skip_serializing_if = "Option::is_none")]
44 pub scoop: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub dnf: Option<String>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub yum: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub emerge: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub apk: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub zypper: Option<String>,
55 #[serde(rename = "nix-env", skip_serializing_if = "Option::is_none")]
56 pub nix_env: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub pip: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub npm: Option<String>,
61
62 #[serde(flatten)]
63 #[serde(default)]
64 pub others: std::collections::HashMap<String, String>,
65}
66
67impl InstallInstructions {
68 pub fn get_command(&self, key: &str) -> Option<&String> {
69 match key {
70 "brew" => self.brew.as_ref(),
71 "apt" => self.apt.as_ref(),
72 "pacman" => self.pacman.as_ref(),
73 "cargo" => self.cargo.as_ref(),
74 "scoop" => self.scoop.as_ref(),
75 "dnf" => self.dnf.as_ref(),
76 "yum" => self.yum.as_ref(),
77 "emerge" => self.emerge.as_ref(),
78 "apk" => self.apk.as_ref(),
79 "zypper" => self.zypper.as_ref(),
80 "nix-env" | "nix_env" => self.nix_env.as_ref(),
81 "pip" => self.pip.as_ref(),
82 "npm" => self.npm.as_ref(),
83 _ => self.others.get(key),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89#[serde(untagged)]
90pub enum StringOrArray {
91 Single(String),
92 Multiple(Vec<String>),
93}
94
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96pub struct OsAliases {
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub linux: Option<StringOrArray>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub macos: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub windows: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct AciCommandContract {
111 pub app_id: String,
113 pub name: String,
115 pub cmd_path: String,
117 pub node_type: NodeType,
119 pub description: String,
121 pub risk_level: RiskLevel,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub example_template: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub os_aliases: Option<OsAliases>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub install_instructions: Option<InstallInstructions>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub docker_image: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub script_url: Option<String>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub source_url: Option<String>,
141 #[serde(default)]
143 pub popularity: f64,
144 #[serde(default)]
148 pub verified: bool,
149 #[serde(default = "default_confidence")]
151 pub confidence: String,
152}
153
154fn default_confidence() -> String {
155 "high".to_string()
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct DbMetadata {
161 pub etag: String,
163 pub version: String,
165 pub updated_at: i64,
167 pub app_count: u64,
169 pub command_count: u64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct UpdateManifest {
176 pub version: String,
178 pub etag: String,
180 pub db_url: String,
182 pub sig_url: String,
184 pub sha256: String,
186 #[serde(default)]
188 pub mode: Option<String>,
189 #[serde(default)]
191 pub new_sync_time: Option<i64>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct DbCommandVec {
197 pub cmd_path: String,
198 pub embedding: Vec<f32>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct IncrementalSyncPayload {
204 pub apps: Vec<DbApp>,
205 pub arguments: Vec<DbArgument>,
206 pub command_vecs: Vec<DbCommandVec>,
207 #[serde(default)]
208 pub deleted_apps: Vec<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
213pub struct DbApp {
214 pub app_id: String,
215 pub name: String,
216 pub os_aliases: Option<String>,
217 pub install_instructions: Option<String>,
218 pub popularity: f64,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223pub struct DbArgument {
224 pub cmd_path: String,
225 pub app_id: String,
226 pub node_name: String,
227 pub node_type: String,
228 pub description: String,
229 pub risk_level: String,
230 pub example_template: Option<String>,
231 pub docker_image: Option<String>,
232 pub script_url: Option<String>,
233 pub source_url: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
241pub struct DbAciRecord {
242 pub app_id: String,
243 pub name: String,
244 pub cmd_path: String,
245 pub node_type: String,
246 pub description: String,
247 pub risk_level: String,
248 pub example_template: Option<String>,
249 pub os_aliases: Option<String>,
250 pub install_instructions: Option<String>,
251 pub popularity: f64,
252 pub docker_image: Option<String>,
253 pub script_url: Option<String>,
254 pub source_url: Option<String>,
255 pub provenance: Option<String>,
258}
259
260impl AciCommandContract {
261 pub fn node_name(&self) -> &str {
263 self.cmd_path.split('.').next_back().unwrap_or(&self.name)
264 }
265
266 pub fn to_db_records(&self) -> Result<(DbApp, DbArgument), crate::error::CmdHubError> {
268 let install_instructions = if let Some(ref inst) = self.install_instructions {
269 Some(serde_json::to_string(inst)?)
270 } else {
271 None
272 };
273
274 let os_aliases = if let Some(ref aliases) = self.os_aliases {
275 Some(serde_json::to_string(aliases)?)
276 } else {
277 None
278 };
279
280 let app = DbApp {
281 app_id: self.app_id.clone(),
282 name: self.name.clone(),
283 os_aliases,
284 install_instructions,
285 popularity: self.popularity,
286 };
287
288 let node_type_str = match self.node_type {
289 NodeType::Root => "root",
290 NodeType::Sub => "sub",
291 NodeType::Arg => "arg",
292 };
293
294 let risk_level_str = match self.risk_level {
295 RiskLevel::Safe => "safe",
296 RiskLevel::Medium => "medium",
297 RiskLevel::Dangerous => "dangerous",
298 };
299
300 let argument = DbArgument {
301 cmd_path: self.cmd_path.clone(),
302 app_id: self.app_id.clone(),
303 node_name: self.node_name().to_string(),
304 node_type: node_type_str.to_string(),
305 description: self.description.clone(),
306 risk_level: risk_level_str.to_string(),
307 example_template: self.example_template.clone(),
308 docker_image: self.docker_image.clone(),
309 script_url: self.script_url.clone(),
310 source_url: self.source_url.clone(),
311 };
312
313 Ok((app, argument))
314 }
315}
316
317impl TryFrom<DbAciRecord> for AciCommandContract {
318 type Error = crate::error::CmdHubError;
319
320 fn try_from(record: DbAciRecord) -> Result<Self, Self::Error> {
321 let node_type = match record.node_type.as_str() {
322 "root" => NodeType::Root,
323 "sub" => NodeType::Sub,
324 "arg" => NodeType::Arg,
325 other => {
326 return Err(crate::error::CmdHubError::Validation(format!(
327 "Invalid node_type in database: '{}'",
328 other
329 )))
330 }
331 };
332
333 let risk_level = match record.risk_level.as_str() {
334 "safe" => RiskLevel::Safe,
335 "medium" => RiskLevel::Medium,
336 "dangerous" => RiskLevel::Dangerous,
337 other => {
338 return Err(crate::error::CmdHubError::Validation(format!(
339 "Invalid risk_level in database: '{}'",
340 other
341 )))
342 }
343 };
344
345 let install_instructions = if let Some(ref inst_str) = record.install_instructions {
346 if inst_str.trim().is_empty() {
347 None
348 } else {
349 Some(serde_json::from_str(inst_str).map_err(|e| {
350 crate::error::CmdHubError::Validation(format!(
351 "Failed to parse install_instructions JSON: {}",
352 e
353 ))
354 })?)
355 }
356 } else {
357 None
358 };
359
360 let os_aliases = if let Some(ref alias_str) = record.os_aliases {
361 if alias_str.trim().is_empty() {
362 None
363 } else {
364 Some(serde_json::from_str(alias_str).map_err(|e| {
365 crate::error::CmdHubError::Validation(format!(
366 "Failed to parse os_aliases JSON: {}",
367 e
368 ))
369 })?)
370 }
371 } else {
372 None
373 };
374
375 Ok(AciCommandContract {
376 app_id: record.app_id,
377 name: record.name,
378 cmd_path: record.cmd_path,
379 node_type,
380 description: record.description,
381 risk_level,
382 example_template: record.example_template,
383 os_aliases,
384 install_instructions,
385 docker_image: record.docker_image,
386 script_url: record.script_url,
387 source_url: record.source_url,
388 popularity: record.popularity,
389 verified: record.provenance.as_deref() == Some("probe"),
390 confidence: "high".to_string(),
391 })
392 }
393}
394
395pub const CREATE_APPS_TABLE: &str = r#"
397CREATE TABLE IF NOT EXISTS apps (
398 app_id TEXT PRIMARY KEY,
399 name TEXT NOT NULL,
400 os_aliases TEXT,
401 install_instructions TEXT,
402 popularity REAL DEFAULT 0.0
403);
404"#;
405
406pub const CREATE_ARGUMENTS_TABLE: &str = r#"
408CREATE TABLE IF NOT EXISTS arguments (
409 cmd_path TEXT PRIMARY KEY,
410 app_id TEXT NOT NULL,
411 node_name TEXT NOT NULL,
412 node_type TEXT NOT NULL,
413 description TEXT NOT NULL,
414 risk_level TEXT NOT NULL,
415 example_template TEXT,
416 docker_image TEXT,
417 script_url TEXT,
418 source_url TEXT,
419 provenance TEXT NOT NULL DEFAULT 'inferred',
420 FOREIGN KEY(app_id) REFERENCES apps(app_id) ON DELETE CASCADE
421);
422"#;
423
424pub const CREATE_APPS_FTS_TABLE: &str = r#"
426CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
427 cmd_path UNINDEXED,
428 name,
429 capabilities
430);
431"#;
432
433pub const CREATE_COMMANDS_VEC_TABLE: &str = r#"
435CREATE VIRTUAL TABLE IF NOT EXISTS commands_vec USING vec0(
436 cmd_path TEXT PRIMARY KEY,
437 embedding float[384]
438);
439"#;
440
441pub const RRF_QUERY: &str = r#"
443WITH fts_rank AS (
444 SELECT cmd_path, row_number() OVER (ORDER BY bm25(apps_fts) ASC) as fts_pos
445 FROM apps_fts WHERE apps_fts MATCH :query
446),
447vec_rank AS (
448 SELECT cmd_path, row_number() OVER (ORDER BY distance ASC) as vec_pos
449 FROM commands_vec
450 WHERE embedding MATCH :query_vector AND k = 100
451)
452SELECT
453 arg.cmd_path, arg.node_name, arg.description, arg.risk_level, arg.example_template,
454 COALESCE(1.0 / (60.0 + fts.fts_pos), 0.0) + COALESCE(1.0 / (60.0 + vec.vec_pos), 0.0) as rrf_score
455FROM arguments arg
456LEFT JOIN fts_rank fts ON arg.cmd_path = fts.cmd_path
457LEFT JOIN vec_rank vec ON arg.cmd_path = vec.cmd_path
458WHERE fts.cmd_path IS NOT NULL OR vec.cmd_path IS NOT NULL
459ORDER BY rrf_score DESC
460LIMIT :limit_num;
461"#;
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn test_aci_serialization_roundtrip() {
469 let contract = AciCommandContract {
470 app_id: "org.github.mtoyoda.sl".to_string(),
471 name: "sl".to_string(),
472 cmd_path: "sl.-l".to_string(),
473 node_type: NodeType::Arg,
474 description: "Display a train moving from left to right".to_string(),
475 risk_level: RiskLevel::Safe,
476 example_template: Some("sl -l".to_string()),
477 os_aliases: Some(OsAliases {
478 linux: Some(StringOrArray::Multiple(vec![
479 "sl-prompt".to_string(),
480 "sl".to_string(),
481 ])),
482 macos: Some("sl".to_string()),
483 windows: None,
484 }),
485 install_instructions: None,
486 docker_image: None,
487 script_url: None,
488 source_url: None,
489 popularity: 0.0,
490 verified: false,
491 confidence: "high".to_string(),
492 };
493
494 let json = serde_json::to_string(&contract).unwrap();
495 let deserialized: AciCommandContract = serde_json::from_str(&json).unwrap();
496 assert_eq!(contract.app_id, deserialized.app_id);
497 assert_eq!(contract.cmd_path, deserialized.cmd_path);
498 assert_eq!(contract.risk_level, deserialized.risk_level);
499 assert_eq!(contract.os_aliases, deserialized.os_aliases);
500 }
501
502 #[test]
503 fn test_risk_level_json_values() {
504 assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), "\"safe\"");
505 assert_eq!(
506 serde_json::to_string(&RiskLevel::Dangerous).unwrap(),
507 "\"dangerous\""
508 );
509 }
510
511 #[test]
512 fn test_db_conversions() {
513 let contract = AciCommandContract {
514 app_id: "org.github.mtoyoda.sl".to_string(),
515 name: "sl".to_string(),
516 cmd_path: "sl.-l".to_string(),
517 node_type: NodeType::Arg,
518 description: "Display a train moving from left to right".to_string(),
519 risk_level: RiskLevel::Safe,
520 example_template: Some("sl -l".to_string()),
521 os_aliases: None,
522 install_instructions: Some(InstallInstructions {
523 brew: Some("brew install sl".to_string()),
524 apt: Some("sudo apt install sl".to_string()),
525 pacman: None,
526 cargo: None,
527 scoop: Some("scoop install sl".to_string()),
528 ..Default::default()
529 }),
530 docker_image: Some("docker.io/library/sl:latest".to_string()),
531 script_url: Some(
532 "https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string(),
533 ),
534 source_url: Some("https://github.com/mtoyoda/sl".to_string()),
535 popularity: 0.8,
536 verified: false,
537 confidence: "high".to_string(),
538 };
539
540 assert_eq!(contract.node_name(), "-l");
542
543 let (db_app, db_arg) = contract.to_db_records().unwrap();
545 assert_eq!(db_app.app_id, "org.github.mtoyoda.sl");
546 assert_eq!(db_app.name, "sl");
547 assert!(db_app
548 .install_instructions
549 .as_ref()
550 .unwrap()
551 .contains("brew install sl"));
552
553 assert_eq!(db_arg.cmd_path, "sl.-l");
554 assert_eq!(db_arg.app_id, "org.github.mtoyoda.sl");
555 assert_eq!(db_arg.node_name, "-l");
556 assert_eq!(db_arg.node_type, "arg");
557 assert_eq!(db_arg.risk_level, "safe");
558 assert_eq!(db_arg.example_template, Some("sl -l".to_string()));
559 assert_eq!(
560 db_arg.docker_image,
561 Some("docker.io/library/sl:latest".to_string())
562 );
563 assert_eq!(
564 db_arg.script_url,
565 Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
566 );
567 assert_eq!(
568 db_arg.source_url,
569 Some("https://github.com/mtoyoda/sl".to_string())
570 );
571
572 let db_record = DbAciRecord {
574 app_id: db_app.app_id,
575 name: db_app.name,
576 cmd_path: db_arg.cmd_path,
577 node_type: db_arg.node_type,
578 description: db_arg.description,
579 risk_level: db_arg.risk_level,
580 example_template: db_arg.example_template,
581 os_aliases: db_app.os_aliases,
582 install_instructions: db_app.install_instructions,
583 popularity: db_app.popularity,
584 docker_image: db_arg.docker_image,
585 script_url: db_arg.script_url,
586 source_url: db_arg.source_url,
587 provenance: Some("probe".to_string()),
588 };
589
590 let reconstructed = AciCommandContract::try_from(db_record).unwrap();
591 assert_eq!(reconstructed.app_id, contract.app_id);
592 assert_eq!(reconstructed.cmd_path, contract.cmd_path);
593 assert_eq!(reconstructed.node_type, contract.node_type);
594 assert_eq!(reconstructed.risk_level, contract.risk_level);
595 assert_eq!(
596 reconstructed.install_instructions.as_ref().unwrap().brew,
597 Some("brew install sl".to_string())
598 );
599 assert_eq!(
600 reconstructed.install_instructions.as_ref().unwrap().scoop,
601 Some("scoop install sl".to_string())
602 );
603 assert_eq!(
604 reconstructed.docker_image,
605 Some("docker.io/library/sl:latest".to_string())
606 );
607 assert_eq!(
608 reconstructed.script_url,
609 Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
610 );
611 assert_eq!(
612 reconstructed.source_url,
613 Some("https://github.com/mtoyoda/sl".to_string())
614 );
615 }
616
617 #[test]
618 fn test_install_instructions_flattened_others() {
619 let json_data = r#"{
620 "brew": "brew install git",
621 "dnf": "dnf install -y git",
622 "apk": "apk add git"
623 }"#;
624 let inst: InstallInstructions = serde_json::from_str(json_data).unwrap();
625 assert_eq!(inst.brew.as_deref(), Some("brew install git"));
626 assert_eq!(
627 inst.get_command("brew").map(|s| s.as_str()),
628 Some("brew install git")
629 );
630 assert_eq!(
631 inst.get_command("dnf").map(|s| s.as_str()),
632 Some("dnf install -y git")
633 );
634 assert_eq!(
635 inst.get_command("apk").map(|s| s.as_str()),
636 Some("apk add git")
637 );
638 assert_eq!(inst.get_command("pacman"), None);
639 }
640}