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
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#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct AciCommandContract {
67 pub app_id: String,
69 pub name: String,
71 pub cmd_path: String,
73 pub node_type: NodeType,
75 pub description: String,
77 pub risk_level: RiskLevel,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub example_template: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub install_instructions: Option<InstallInstructions>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DbMetadata {
90 pub etag: String,
92 pub version: String,
94 pub updated_at: i64,
96 pub app_count: u64,
98 pub command_count: u64,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct UpdateManifest {
105 pub version: String,
107 pub etag: String,
109 pub db_url: String,
111 pub sig_url: String,
113 pub sha256: String,
115}
116
117#[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#[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#[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 pub fn node_name(&self) -> &str {
156 self.cmd_path.split('.').next_back().unwrap_or(&self.name)
157 }
158
159 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
255pub 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
264pub 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
278pub 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
287pub 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
295pub 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 assert_eq!(contract.node_name(), "-l");
370
371 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 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}