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)), }
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), }
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 #[arg(short, long, group = "query_source")]
68 pub query: Option<String>,
69
70 #[arg(short, long, group = "query_source")]
72 pub file: Option<PathBuf>,
73
74 #[arg(long = "var")]
76 pub vars: Vec<String>,
77
78 #[arg(long, value_enum, default_value = "raw")]
80 pub format: OutputFormat,
81}
82
83#[derive(clap::Subcommand, Debug)]
84enum Commands {
85 Preset {
87 #[command(subcommand)]
88 action: PresetAction,
89 },
90}
91
92#[derive(clap::Subcommand, Debug)]
93enum PresetAction {
94 List,
96 Run {
98 name: String,
100
101 #[arg(long = "param")]
103 params: Vec<String>,
104
105 #[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
135fn 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, ¶ms_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 ¶ms {
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 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
299pub 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
319pub 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}