Skip to main content

git_seek/
lib.rs

1pub mod presets;
2
3use clap::Parser;
4use comfy_table::{Table, presets::UTF8_FULL};
5use git2::Repository;
6use serde_json::{Map, Value};
7use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
8use trustfall_git_adapter::GitAdapter;
9
10fn convert_trustfall_value_to_json(value: &trustfall::FieldValue) -> Value {
11    match value {
12        trustfall::FieldValue::Null => Value::Null,
13        trustfall::FieldValue::Int64(n) => Value::Number((*n).into()),
14        trustfall::FieldValue::Uint64(n) => Value::Number((*n).into()),
15        trustfall::FieldValue::Float64(f) => serde_json::Number::from_f64(*f)
16            .map(Value::Number)
17            .unwrap_or(Value::Null),
18        trustfall::FieldValue::String(s) => Value::String(s.to_string()),
19        trustfall::FieldValue::Boolean(b) => Value::Bool(*b),
20        trustfall::FieldValue::List(items) => {
21            Value::Array(items.iter().map(convert_trustfall_value_to_json).collect())
22        }
23        _ => Value::String(format!("{:?}", value)), // fallback for other types
24    }
25}
26
27fn format_trustfall_value_for_table(value: &trustfall::FieldValue) -> String {
28    match value {
29        trustfall::FieldValue::Null => "null".to_string(),
30        trustfall::FieldValue::Int64(n) => n.to_string(),
31        trustfall::FieldValue::Uint64(n) => n.to_string(),
32        trustfall::FieldValue::Float64(f) => f.to_string(),
33        trustfall::FieldValue::String(s) => s.to_string(),
34        trustfall::FieldValue::Boolean(b) => b.to_string(),
35        trustfall::FieldValue::List(items) => {
36            format!(
37                "[{}]",
38                items
39                    .iter()
40                    .map(format_trustfall_value_for_table)
41                    .collect::<Vec<_>>()
42                    .join(", ")
43            )
44        }
45        _ => format!("{:?}", value), // fallback for other types
46    }
47}
48
49fn convert_result_row_to_json(row: &BTreeMap<std::sync::Arc<str>, trustfall::FieldValue>) -> Value {
50    let mut map = Map::new();
51    for (key, value) in row {
52        map.insert(key.to_string(), convert_trustfall_value_to_json(value));
53    }
54    Value::Object(map)
55}
56
57#[derive(Parser, Debug)]
58#[command(
59    name = "git-seek",
60    about = "Run Trustfall queries against a Git repository"
61)]
62pub struct Cli {
63    #[command(subcommand)]
64    command: Option<Commands>,
65
66    /// Inline Trustfall query
67    #[arg(short, long, group = "query_source")]
68    pub query: Option<String>,
69
70    /// Path to query file
71    #[arg(short, long, group = "query_source")]
72    pub file: Option<PathBuf>,
73
74    /// Optional variables: --var name=value
75    #[arg(long = "var")]
76    pub vars: Vec<String>,
77
78    /// Output format
79    #[arg(long, value_enum, default_value = "raw")]
80    pub format: OutputFormat,
81}
82
83#[derive(clap::Subcommand, Debug)]
84enum Commands {
85    /// Run a preset query
86    Preset {
87        #[command(subcommand)]
88        action: PresetAction,
89    },
90}
91
92#[derive(clap::Subcommand, Debug)]
93enum PresetAction {
94    /// List all available presets
95    List,
96    /// Run a specific preset
97    Run {
98        /// Name of the preset to run
99        name: String,
100
101        /// Preset parameters: --param name=value
102        #[arg(long = "param")]
103        params: Vec<String>,
104
105        /// Output format
106        #[arg(long, value_enum, default_value = "raw")]
107        format: OutputFormat,
108    },
109}
110
111#[derive(clap::ValueEnum, Clone, Debug, PartialEq)]
112pub enum OutputFormat {
113    Table,
114    Json,
115    Raw,
116}
117
118use std::io::{self, IsTerminal, Read};
119
120fn load_query(query: &Option<String>, file: &Option<PathBuf>) -> anyhow::Result<String> {
121    if let Some(q) = query {
122        return Ok(q.clone());
123    }
124    if let Some(path) = file {
125        return Ok(std::fs::read_to_string(path)?);
126    }
127    let mut input = String::new();
128    if io::stdin().is_terminal() {
129        anyhow::bail!("No query provided. Use --query, --file, or pipe via stdin.");
130    }
131    io::stdin().read_to_string(&mut input)?;
132    Ok(input)
133}
134
135/// Coerce a string CLI variable into a typed Trustfall FieldValue.
136/// Tries integer, then float, then falls back to string.
137fn coerce_variable(value: &str) -> trustfall::FieldValue {
138    if let Ok(n) = value.parse::<i64>() {
139        trustfall::FieldValue::Int64(n)
140    } else if let Ok(f) = value.parse::<f64>() {
141        trustfall::FieldValue::Float64(f)
142    } else {
143        trustfall::FieldValue::String(value.into())
144    }
145}
146
147fn execute_and_output(
148    adapter: &GitAdapter<'_>,
149    query: &str,
150    variables: BTreeMap<&str, &str>,
151    format: &OutputFormat,
152) -> anyhow::Result<()> {
153    let typed_variables: BTreeMap<&str, trustfall::FieldValue> = variables
154        .into_iter()
155        .map(|(k, v)| (k, coerce_variable(v)))
156        .collect();
157    let result =
158        trustfall::execute_query(adapter.schema(), Arc::new(adapter), query, typed_variables)?;
159
160    match format {
161        OutputFormat::Json => {
162            let results: Vec<Value> = result.map(|row| convert_result_row_to_json(&row)).collect();
163            println!("{}", serde_json::to_string_pretty(&results)?);
164        }
165        OutputFormat::Table => {
166            let rows: Vec<_> = result.collect();
167            if rows.is_empty() {
168                return Ok(());
169            }
170            let columns: Vec<String> = rows[0].keys().map(|k| k.to_string()).collect();
171            let mut table = Table::new();
172            table.load_preset(UTF8_FULL).set_header(&columns);
173            for row in &rows {
174                let row_values = columns.iter().map(|col| match row.get(col.as_str()) {
175                    Some(value) => format_trustfall_value_for_table(value),
176                    None => String::new(),
177                });
178                table.add_row(row_values);
179            }
180            println!("{table}");
181        }
182        OutputFormat::Raw => {
183            for row in result {
184                println!("{:?}", row);
185            }
186        }
187    }
188    Ok(())
189}
190
191fn run_preset(adapter: &GitAdapter<'_>, action: PresetAction) -> anyhow::Result<()> {
192    match action {
193        PresetAction::List => {
194            let mut table = Table::new();
195            table
196                .load_preset(UTF8_FULL)
197                .set_header(vec!["Name", "Description", "Parameters"]);
198
199            for preset in presets::all_presets() {
200                let params_str = if preset.params.is_empty() {
201                    "(none)".to_string()
202                } else {
203                    preset
204                        .params
205                        .iter()
206                        .map(|p| {
207                            if let Some(default) = p.default {
208                                format!("--{}: {} (default: {})", p.name, p.description, default)
209                            } else {
210                                format!("--{}: {} (required)", p.name, p.description)
211                            }
212                        })
213                        .collect::<Vec<_>>()
214                        .join(", ")
215                };
216                table.add_row(vec![preset.name, preset.description, &params_str]);
217            }
218            println!("{table}");
219            Ok(())
220        }
221        PresetAction::Run {
222            name,
223            params,
224            format,
225        } => {
226            let preset = presets::find_preset(&name).ok_or_else(|| {
227                anyhow::anyhow!(
228                    "Unknown preset: '{}'. Run 'git-seek preset list' to see available presets.",
229                    name
230                )
231            })?;
232
233            let mut user_params = BTreeMap::new();
234            for p in &params {
235                match p.split_once("=") {
236                    Some((k, v)) => {
237                        user_params.insert(k, v);
238                    }
239                    None => {
240                        anyhow::bail!(
241                            "Invalid parameter format '{}'. Expected '--param name=value'.",
242                            p
243                        );
244                    }
245                }
246            }
247
248            let mut variables = BTreeMap::new();
249            let mut inline_replacements = Vec::new();
250            for param in preset.params {
251                let resolved_value = if let Some(value) = user_params.get(param.name) {
252                    Some(*value)
253                } else if let Some(default) = param.default {
254                    Some(default)
255                } else if param.required {
256                    anyhow::bail!(
257                        "Missing required parameter '--param {}=<value>' for preset '{}'",
258                        param.name,
259                        preset.name
260                    );
261                } else {
262                    None
263                };
264
265                if let Some(value) = resolved_value {
266                    if param.inline {
267                        if value.parse::<i64>().is_err() {
268                            anyhow::bail!(
269                                "Parameter '{}' must be an integer, got '{}'",
270                                param.name,
271                                value
272                            );
273                        }
274                        inline_replacements.push((param.name, value));
275                    } else {
276                        variables.insert(param.name, value);
277                    }
278                }
279            }
280
281            // Inline params are substituted directly into the query string because
282            // Trustfall does not support variables in edge arguments (only in @filter).
283            // Safe here because preset queries are controlled constants.
284            let query = if inline_replacements.is_empty() {
285                preset.query.to_string()
286            } else {
287                let mut q = preset.query.to_string();
288                for (name, value) in &inline_replacements {
289                    q = q.replace(&format!("${}", name), value);
290                }
291                q
292            };
293
294            execute_and_output(adapter, &query, variables, &format)
295        }
296    }
297}
298
299/// Run the CLI with the given parsed arguments and a specific repo path.
300pub fn run_with_repo(cli: Cli, repo_path: &std::path::Path) -> anyhow::Result<()> {
301    let repo = Repository::open(repo_path)?;
302    let adapter = GitAdapter::new(&repo);
303
304    match cli.command {
305        Some(Commands::Preset { action }) => run_preset(&adapter, action),
306        None => {
307            let variables = cli
308                .vars
309                .iter()
310                .filter_map(|var_entry| var_entry.split_once("="))
311                .collect::<BTreeMap<_, _>>();
312
313            let query = load_query(&cli.query, &cli.file)?;
314            execute_and_output(&adapter, &query, variables, &cli.format)
315        }
316    }
317}
318
319/// Run the CLI using the repository from the current environment.
320pub fn run(cli: Cli) -> anyhow::Result<()> {
321    let repo = Repository::open_from_env()?;
322    let adapter = GitAdapter::new(&repo);
323
324    match cli.command {
325        Some(Commands::Preset { action }) => run_preset(&adapter, action),
326        None => {
327            let variables = cli
328                .vars
329                .iter()
330                .filter_map(|var_entry| var_entry.split_once("="))
331                .collect::<BTreeMap<_, _>>();
332
333            let query = load_query(&cli.query, &cli.file)?;
334            execute_and_output(&adapter, &query, variables, &cli.format)
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use serde_json::json;
343    use std::sync::Arc;
344
345    #[test]
346    fn test_convert_trustfall_value_to_json_string() {
347        let value = trustfall::FieldValue::String("test".into());
348        let result = convert_trustfall_value_to_json(&value);
349        assert_eq!(result, json!("test"));
350    }
351
352    #[test]
353    fn test_convert_trustfall_value_to_json_int64() {
354        let value = trustfall::FieldValue::Int64(42);
355        let result = convert_trustfall_value_to_json(&value);
356        assert_eq!(result, json!(42));
357    }
358
359    #[test]
360    fn test_convert_trustfall_value_to_json_uint64() {
361        let value = trustfall::FieldValue::Uint64(42);
362        let result = convert_trustfall_value_to_json(&value);
363        assert_eq!(result, json!(42));
364    }
365
366    #[test]
367    fn test_convert_trustfall_value_to_json_float64() {
368        let value = trustfall::FieldValue::Float64(3.14);
369        let result = convert_trustfall_value_to_json(&value);
370        assert_eq!(result, json!(3.14));
371    }
372
373    #[test]
374    fn test_convert_trustfall_value_to_json_boolean() {
375        let value = trustfall::FieldValue::Boolean(true);
376        let result = convert_trustfall_value_to_json(&value);
377        assert_eq!(result, json!(true));
378
379        let value = trustfall::FieldValue::Boolean(false);
380        let result = convert_trustfall_value_to_json(&value);
381        assert_eq!(result, json!(false));
382    }
383
384    #[test]
385    fn test_convert_trustfall_value_to_json_null() {
386        let value = trustfall::FieldValue::Null;
387        let result = convert_trustfall_value_to_json(&value);
388        assert_eq!(result, json!(null));
389    }
390
391    #[test]
392    fn test_convert_trustfall_value_to_json_list() {
393        let value = trustfall::FieldValue::List(
394            vec![
395                trustfall::FieldValue::String("a".into()),
396                trustfall::FieldValue::Int64(1),
397                trustfall::FieldValue::Boolean(true),
398            ]
399            .into(),
400        );
401        let result = convert_trustfall_value_to_json(&value);
402        assert_eq!(result, json!(["a", 1, true]));
403    }
404
405    #[test]
406    fn test_format_trustfall_value_for_table_string() {
407        let value = trustfall::FieldValue::String("test".into());
408        let result = format_trustfall_value_for_table(&value);
409        assert_eq!(result, "test");
410    }
411
412    #[test]
413    fn test_format_trustfall_value_for_table_numbers() {
414        let value = trustfall::FieldValue::Int64(-42);
415        assert_eq!(format_trustfall_value_for_table(&value), "-42");
416
417        let value = trustfall::FieldValue::Uint64(42);
418        assert_eq!(format_trustfall_value_for_table(&value), "42");
419
420        let value = trustfall::FieldValue::Float64(3.14);
421        assert_eq!(format_trustfall_value_for_table(&value), "3.14");
422    }
423
424    #[test]
425    fn test_format_trustfall_value_for_table_boolean() {
426        let value = trustfall::FieldValue::Boolean(true);
427        assert_eq!(format_trustfall_value_for_table(&value), "true");
428
429        let value = trustfall::FieldValue::Boolean(false);
430        assert_eq!(format_trustfall_value_for_table(&value), "false");
431    }
432
433    #[test]
434    fn test_format_trustfall_value_for_table_null() {
435        let value = trustfall::FieldValue::Null;
436        assert_eq!(format_trustfall_value_for_table(&value), "null");
437    }
438
439    #[test]
440    fn test_format_trustfall_value_for_table_list() {
441        let value = trustfall::FieldValue::List(
442            vec![
443                trustfall::FieldValue::String("a".into()),
444                trustfall::FieldValue::Int64(1),
445            ]
446            .into(),
447        );
448        let result = format_trustfall_value_for_table(&value);
449        assert_eq!(result, "[a, 1]");
450    }
451
452    #[test]
453    fn test_format_trustfall_value_for_table_empty_list() {
454        let value = trustfall::FieldValue::List(vec![].into());
455        let result = format_trustfall_value_for_table(&value);
456        assert_eq!(result, "[]");
457    }
458
459    #[test]
460    fn test_convert_result_row_to_json() {
461        let mut row = BTreeMap::new();
462        row.insert(
463            Arc::from("name"),
464            trustfall::FieldValue::String("test-repo".into()),
465        );
466        row.insert(Arc::from("count"), trustfall::FieldValue::Int64(42));
467        row.insert(Arc::from("active"), trustfall::FieldValue::Boolean(true));
468
469        let result = convert_result_row_to_json(&row);
470        let expected = json!({
471            "name": "test-repo",
472            "count": 42,
473            "active": true
474        });
475        assert_eq!(result, expected);
476    }
477
478    #[test]
479    fn test_convert_result_row_to_json_empty() {
480        let row = BTreeMap::new();
481        let result = convert_result_row_to_json(&row);
482        assert_eq!(result, json!({}));
483    }
484
485    #[test]
486    fn test_load_query_inline() {
487        let query = Some("test query".to_string());
488        let result = load_query(&query, &None).unwrap();
489        assert_eq!(result, "test query");
490    }
491
492    #[test]
493    fn test_load_query_file() {
494        use std::io::Write;
495        let mut temp_file = tempfile::NamedTempFile::new().unwrap();
496        writeln!(temp_file, "file query content").unwrap();
497        let result = load_query(&None, &Some(temp_file.path().to_path_buf())).unwrap();
498        assert_eq!(result, "file query content\n");
499    }
500
501    #[test]
502    fn test_load_query_file_not_found() {
503        assert!(load_query(&None, &Some(PathBuf::from("/nonexistent/file.txt"))).is_err());
504    }
505
506    #[test]
507    fn test_load_query_priority_inline_over_file() {
508        use std::io::Write;
509        let mut temp_file = tempfile::NamedTempFile::new().unwrap();
510        writeln!(temp_file, "file content").unwrap();
511        let query = Some("inline query".to_string());
512        let result = load_query(&query, &Some(temp_file.path().to_path_buf())).unwrap();
513        assert_eq!(result, "inline query");
514    }
515
516    #[test]
517    fn test_output_format_values() {
518        use clap::ValueEnum;
519        let formats = OutputFormat::value_variants();
520        assert_eq!(formats.len(), 3);
521        assert!(formats.contains(&OutputFormat::Table));
522        assert!(formats.contains(&OutputFormat::Json));
523        assert!(formats.contains(&OutputFormat::Raw));
524    }
525
526    #[test]
527    fn test_coerce_variable_integer() {
528        assert_eq!(coerce_variable("42"), trustfall::FieldValue::Int64(42));
529    }
530
531    #[test]
532    fn test_coerce_variable_negative_integer() {
533        assert_eq!(coerce_variable("-7"), trustfall::FieldValue::Int64(-7));
534    }
535
536    #[test]
537    fn test_coerce_variable_float() {
538        assert_eq!(
539            coerce_variable("3.14"),
540            trustfall::FieldValue::Float64(3.14)
541        );
542    }
543
544    #[test]
545    fn test_coerce_variable_string() {
546        assert_eq!(
547            coerce_variable("hello"),
548            trustfall::FieldValue::String("hello".into())
549        );
550    }
551}