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)]
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
46 #[serde(flatten)]
47 #[serde(default)]
48 pub others: std::collections::HashMap<String, String>,
49}
50
51impl InstallInstructions {
52 pub fn get_command(&self, key: &str) -> Option<&String> {
53 match key {
54 "brew" => self.brew.as_ref(),
55 "apt" => self.apt.as_ref(),
56 "pacman" => self.pacman.as_ref(),
57 "cargo" => self.cargo.as_ref(),
58 "scoop" => self.scoop.as_ref(),
59 _ => self.others.get(key),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct AciCommandContract {
70 pub app_id: String,
72 pub name: String,
74 pub cmd_path: String,
76 pub node_type: NodeType,
78 pub description: String,
80 pub risk_level: RiskLevel,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub example_template: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub install_instructions: Option<InstallInstructions>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub docker_image: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub script_url: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub source_url: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct DbMetadata {
102 pub etag: String,
104 pub version: String,
106 pub updated_at: i64,
108 pub app_count: u64,
110 pub command_count: u64,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct UpdateManifest {
117 pub version: String,
119 pub etag: String,
121 pub db_url: String,
123 pub sig_url: String,
125 pub sha256: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131pub struct DbApp {
132 pub app_id: String,
133 pub name: String,
134 pub install_instructions: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
139pub struct DbArgument {
140 pub cmd_path: String,
141 pub app_id: String,
142 pub node_name: String,
143 pub node_type: String,
144 pub description: String,
145 pub risk_level: String,
146 pub example_template: Option<String>,
147 pub docker_image: Option<String>,
148 pub script_url: Option<String>,
149 pub source_url: Option<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
157pub struct DbAciRecord {
158 pub app_id: String,
159 pub name: String,
160 pub cmd_path: String,
161 pub node_type: String,
162 pub description: String,
163 pub risk_level: String,
164 pub example_template: Option<String>,
165 pub install_instructions: Option<String>,
166 pub docker_image: Option<String>,
167 pub script_url: Option<String>,
168 pub source_url: Option<String>,
169}
170
171impl AciCommandContract {
172 pub fn node_name(&self) -> &str {
174 self.cmd_path.split('.').next_back().unwrap_or(&self.name)
175 }
176
177 pub fn to_db_records(&self) -> Result<(DbApp, DbArgument), crate::error::CmdHubError> {
179 let install_instructions = if let Some(ref inst) = self.install_instructions {
180 Some(serde_json::to_string(inst)?)
181 } else {
182 None
183 };
184
185 let app = DbApp {
186 app_id: self.app_id.clone(),
187 name: self.name.clone(),
188 install_instructions,
189 };
190
191 let node_type_str = match self.node_type {
192 NodeType::Root => "root",
193 NodeType::Sub => "sub",
194 NodeType::Arg => "arg",
195 };
196
197 let risk_level_str = match self.risk_level {
198 RiskLevel::Safe => "safe",
199 RiskLevel::Medium => "medium",
200 RiskLevel::Dangerous => "dangerous",
201 };
202
203 let argument = DbArgument {
204 cmd_path: self.cmd_path.clone(),
205 app_id: self.app_id.clone(),
206 node_name: self.node_name().to_string(),
207 node_type: node_type_str.to_string(),
208 description: self.description.clone(),
209 risk_level: risk_level_str.to_string(),
210 example_template: self.example_template.clone(),
211 docker_image: self.docker_image.clone(),
212 script_url: self.script_url.clone(),
213 source_url: self.source_url.clone(),
214 };
215
216 Ok((app, argument))
217 }
218}
219
220impl TryFrom<DbAciRecord> for AciCommandContract {
221 type Error = crate::error::CmdHubError;
222
223 fn try_from(record: DbAciRecord) -> Result<Self, Self::Error> {
224 let node_type = match record.node_type.as_str() {
225 "root" => NodeType::Root,
226 "sub" => NodeType::Sub,
227 "arg" => NodeType::Arg,
228 other => {
229 return Err(crate::error::CmdHubError::Validation(format!(
230 "Invalid node_type in database: '{}'",
231 other
232 )))
233 }
234 };
235
236 let risk_level = match record.risk_level.as_str() {
237 "safe" => RiskLevel::Safe,
238 "medium" => RiskLevel::Medium,
239 "dangerous" => RiskLevel::Dangerous,
240 other => {
241 return Err(crate::error::CmdHubError::Validation(format!(
242 "Invalid risk_level in database: '{}'",
243 other
244 )))
245 }
246 };
247
248 let install_instructions = if let Some(ref inst_str) = record.install_instructions {
249 if inst_str.trim().is_empty() {
250 None
251 } else {
252 Some(serde_json::from_str(inst_str).map_err(|e| {
253 crate::error::CmdHubError::Validation(format!(
254 "Failed to parse install_instructions JSON: {}",
255 e
256 ))
257 })?)
258 }
259 } else {
260 None
261 };
262
263 Ok(AciCommandContract {
264 app_id: record.app_id,
265 name: record.name,
266 cmd_path: record.cmd_path,
267 node_type,
268 description: record.description,
269 risk_level,
270 example_template: record.example_template,
271 install_instructions,
272 docker_image: record.docker_image,
273 script_url: record.script_url,
274 source_url: record.source_url,
275 })
276 }
277}
278
279pub const CREATE_APPS_TABLE: &str = r#"
281CREATE TABLE IF NOT EXISTS apps (
282 app_id TEXT PRIMARY KEY,
283 name TEXT NOT NULL,
284 install_instructions TEXT
285);
286"#;
287
288pub const CREATE_ARGUMENTS_TABLE: &str = r#"
290CREATE TABLE IF NOT EXISTS arguments (
291 cmd_path TEXT PRIMARY KEY,
292 app_id TEXT NOT NULL,
293 node_name TEXT NOT NULL,
294 node_type TEXT NOT NULL,
295 description TEXT NOT NULL,
296 risk_level TEXT NOT NULL,
297 example_template TEXT,
298 docker_image TEXT,
299 script_url TEXT,
300 source_url TEXT,
301 FOREIGN KEY(app_id) REFERENCES apps(app_id) ON DELETE CASCADE
302);
303"#;
304
305pub const CREATE_APPS_FTS_TABLE: &str = r#"
307CREATE VIRTUAL TABLE IF NOT EXISTS apps_fts USING fts5(
308 cmd_path UNINDEXED,
309 name,
310 capabilities
311);
312"#;
313
314pub const CREATE_COMMANDS_VEC_TABLE: &str = r#"
316CREATE VIRTUAL TABLE IF NOT EXISTS commands_vec USING vec0(
317 cmd_path TEXT PRIMARY KEY,
318 embedding float[512]
319);
320"#;
321
322pub const RRF_QUERY: &str = r#"
324WITH fts_rank AS (
325 SELECT cmd_path, row_number() OVER (ORDER BY bm25(apps_fts) ASC) as fts_pos
326 FROM apps_fts WHERE apps_fts MATCH :query
327),
328vec_rank AS (
329 SELECT cmd_path, row_number() OVER (ORDER BY distance ASC) as vec_pos
330 FROM commands_vec
331 WHERE embedding MATCH :query_vector AND k = 100
332)
333SELECT
334 arg.cmd_path, arg.node_name, arg.description, arg.risk_level, arg.example_template,
335 COALESCE(1.0 / (60.0 + fts.fts_pos), 0.0) + COALESCE(1.0 / (60.0 + vec.vec_pos), 0.0) as rrf_score
336FROM arguments arg
337LEFT JOIN fts_rank fts ON arg.cmd_path = fts.cmd_path
338LEFT JOIN vec_rank vec ON arg.cmd_path = vec.cmd_path
339WHERE fts.cmd_path IS NOT NULL OR vec.cmd_path IS NOT NULL
340ORDER BY rrf_score DESC
341LIMIT :limit_num;
342"#;
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_aci_serialization_roundtrip() {
350 let contract = AciCommandContract {
351 app_id: "org.github.mtoyoda.sl".to_string(),
352 name: "sl".to_string(),
353 cmd_path: "sl.-l".to_string(),
354 node_type: NodeType::Arg,
355 description: "Display a train moving from left to right".to_string(),
356 risk_level: RiskLevel::Safe,
357 example_template: Some("sl -l".to_string()),
358 install_instructions: None,
359 docker_image: None,
360 script_url: None,
361 source_url: None,
362 };
363
364 let json = serde_json::to_string(&contract).unwrap();
365 let deserialized: AciCommandContract = serde_json::from_str(&json).unwrap();
366 assert_eq!(contract.app_id, deserialized.app_id);
367 assert_eq!(contract.cmd_path, deserialized.cmd_path);
368 assert_eq!(contract.risk_level, deserialized.risk_level);
369 }
370
371 #[test]
372 fn test_risk_level_json_values() {
373 assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), "\"safe\"");
374 assert_eq!(
375 serde_json::to_string(&RiskLevel::Dangerous).unwrap(),
376 "\"dangerous\""
377 );
378 }
379
380 #[test]
381 fn test_db_conversions() {
382 let contract = AciCommandContract {
383 app_id: "org.github.mtoyoda.sl".to_string(),
384 name: "sl".to_string(),
385 cmd_path: "sl.-l".to_string(),
386 node_type: NodeType::Arg,
387 description: "Display a train moving from left to right".to_string(),
388 risk_level: RiskLevel::Safe,
389 example_template: Some("sl -l".to_string()),
390 install_instructions: Some(InstallInstructions {
391 brew: Some("brew install sl".to_string()),
392 apt: Some("sudo apt install sl".to_string()),
393 pacman: None,
394 cargo: None,
395 scoop: Some("scoop install sl".to_string()),
396 ..Default::default()
397 }),
398 docker_image: Some("docker.io/library/sl:latest".to_string()),
399 script_url: Some(
400 "https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string(),
401 ),
402 source_url: Some("https://github.com/mtoyoda/sl".to_string()),
403 };
404
405 assert_eq!(contract.node_name(), "-l");
407
408 let (db_app, db_arg) = contract.to_db_records().unwrap();
410 assert_eq!(db_app.app_id, "org.github.mtoyoda.sl");
411 assert_eq!(db_app.name, "sl");
412 assert!(db_app
413 .install_instructions
414 .as_ref()
415 .unwrap()
416 .contains("brew install sl"));
417
418 assert_eq!(db_arg.cmd_path, "sl.-l");
419 assert_eq!(db_arg.app_id, "org.github.mtoyoda.sl");
420 assert_eq!(db_arg.node_name, "-l");
421 assert_eq!(db_arg.node_type, "arg");
422 assert_eq!(db_arg.risk_level, "safe");
423 assert_eq!(db_arg.example_template, Some("sl -l".to_string()));
424 assert_eq!(
425 db_arg.docker_image,
426 Some("docker.io/library/sl:latest".to_string())
427 );
428 assert_eq!(
429 db_arg.script_url,
430 Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
431 );
432 assert_eq!(
433 db_arg.source_url,
434 Some("https://github.com/mtoyoda/sl".to_string())
435 );
436
437 let db_record = DbAciRecord {
439 app_id: db_app.app_id,
440 name: db_app.name,
441 cmd_path: db_arg.cmd_path,
442 node_type: db_arg.node_type,
443 description: db_arg.description,
444 risk_level: db_arg.risk_level,
445 example_template: db_arg.example_template,
446 install_instructions: db_app.install_instructions,
447 docker_image: db_arg.docker_image,
448 script_url: db_arg.script_url,
449 source_url: db_arg.source_url,
450 };
451
452 let reconstructed = AciCommandContract::try_from(db_record).unwrap();
453 assert_eq!(reconstructed.app_id, contract.app_id);
454 assert_eq!(reconstructed.cmd_path, contract.cmd_path);
455 assert_eq!(reconstructed.node_type, contract.node_type);
456 assert_eq!(reconstructed.risk_level, contract.risk_level);
457 assert_eq!(
458 reconstructed.install_instructions.as_ref().unwrap().brew,
459 Some("brew install sl".to_string())
460 );
461 assert_eq!(
462 reconstructed.install_instructions.as_ref().unwrap().scoop,
463 Some("scoop install sl".to_string())
464 );
465 assert_eq!(
466 reconstructed.docker_image,
467 Some("docker.io/library/sl:latest".to_string())
468 );
469 assert_eq!(
470 reconstructed.script_url,
471 Some("https://raw.githubusercontent.com/mtoyoda/sl/master/install.sh".to_string())
472 );
473 assert_eq!(
474 reconstructed.source_url,
475 Some("https://github.com/mtoyoda/sl".to_string())
476 );
477 }
478
479 #[test]
480 fn test_install_instructions_flattened_others() {
481 let json_data = r#"{
482 "brew": "brew install git",
483 "dnf": "dnf install -y git",
484 "apk": "apk add git"
485 }"#;
486 let inst: InstallInstructions = serde_json::from_str(json_data).unwrap();
487 assert_eq!(inst.brew.as_deref(), Some("brew install git"));
488 assert_eq!(
489 inst.get_command("brew").map(|s| s.as_str()),
490 Some("brew install git")
491 );
492 assert_eq!(
493 inst.get_command("dnf").map(|s| s.as_str()),
494 Some("dnf install -y git")
495 );
496 assert_eq!(
497 inst.get_command("apk").map(|s| s.as_str()),
498 Some("apk add git")
499 );
500 assert_eq!(inst.get_command("pacman"), None);
501 }
502}