1use std::path::Path;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Config {
26 pub enabled: bool,
28 pub rtk_bin: String,
31 pub ultra_compact: bool,
33 pub skip_env: bool,
35}
36
37impl Default for Config {
38 fn default() -> Self {
39 Self {
40 enabled: true,
41 rtk_bin: "rtk".to_string(),
42 ultra_compact: false,
43 skip_env: false,
44 }
45 }
46}
47
48impl Config {
49 pub fn from_markdown(source: &str) -> Self {
56 let mut config = Config::default();
57 let Some(frontmatter) = extract_frontmatter(source) else {
58 return config;
59 };
60
61 for line in frontmatter.lines() {
62 let line = line.trim();
63 if line.is_empty() || line.starts_with('#') {
64 continue;
65 }
66 let Some((key, value)) = line.split_once(':') else {
67 continue;
68 };
69 let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
70 match key.trim() {
71 "enabled" => {
72 if let Some(b) = parse_bool(value) {
73 config.enabled = b;
74 }
75 }
76 "rtk_bin" => {
77 if !value.is_empty() {
78 config.rtk_bin = value.to_string();
79 }
80 }
81 "ultra_compact" => {
82 if let Some(b) = parse_bool(value) {
83 config.ultra_compact = b;
84 }
85 }
86 "skip_env" => {
87 if let Some(b) = parse_bool(value) {
88 config.skip_env = b;
89 }
90 }
91 _ => {}
92 }
93 }
94 config
95 }
96
97 pub fn load(project_dir: &Path) -> Self {
100 let path = project_dir.join(".claude").join("rtk-mcp-cc.local.md");
101 let mut config = match std::fs::read_to_string(path) {
102 Ok(text) => Config::from_markdown(&text),
103 Err(_) => Config::default(),
104 };
105 config.apply_env(|key| std::env::var(key).ok());
106 config
107 }
108
109 pub fn apply_env<F>(&mut self, get: F)
115 where
116 F: Fn(&str) -> Option<String>,
117 {
118 if let Some(v) = get("RTK_BIN") {
119 if !v.trim().is_empty() {
120 self.rtk_bin = v.trim().to_string();
121 }
122 }
123 if let Some(v) = get("RTK_CC_DISABLE") {
124 if parse_bool(&v).unwrap_or(false) {
125 self.enabled = false;
126 }
127 }
128 if let Some(v) = get("RTK_CC_ULTRA_COMPACT") {
129 if let Some(b) = parse_bool(&v) {
130 self.ultra_compact = b;
131 }
132 }
133 if let Some(v) = get("RTK_CC_SKIP_ENV") {
134 if let Some(b) = parse_bool(&v) {
135 self.skip_env = b;
136 }
137 }
138 }
139
140 fn global_flags(&self) -> Vec<String> {
142 let mut flags = Vec::new();
143 if self.ultra_compact {
144 flags.push("--ultra-compact".to_string());
145 }
146 if self.skip_env {
147 flags.push("--skip-env".to_string());
148 }
149 flags
150 }
151}
152
153fn parse_bool(value: &str) -> Option<bool> {
155 match value.trim().to_ascii_lowercase().as_str() {
156 "1" | "true" | "yes" | "on" => Some(true),
157 "0" | "false" | "no" | "off" => Some(false),
158 _ => None,
159 }
160}
161
162fn extract_frontmatter(source: &str) -> Option<&str> {
164 let rest = source.strip_prefix("---")?.trim_start_matches(['\r', '\n']);
165 let end = rest.find("\n---")?;
166 Some(&rest[..end])
167}
168
169pub fn hook_args(config: &Config) -> Vec<String> {
173 let mut args = vec!["hook".to_string(), "claude".to_string()];
174 args.extend(config.global_flags());
175 args
176}
177
178pub fn delegate<F>(stdin_payload: &str, config: &Config, run: F) -> String
193where
194 F: FnOnce(&[String], &str) -> Option<String>,
195{
196 if !config.enabled {
197 return String::new();
198 }
199 if stdin_payload.trim().is_empty() {
200 return String::new();
201 }
202
203 let Some(stdout) = run(&hook_args(config), stdin_payload) else {
204 return String::new();
205 };
206 if stdout.trim().is_empty() {
207 return String::new();
208 }
209
210 if serde_json::from_str::<serde_json::Value>(stdout.trim()).is_err() {
216 tracing::warn!("rtk produced non-JSON stdout; passing the command through unchanged");
217 return String::new();
218 }
219 stdout
220}
221
222#[derive(Debug, Default, Clone)]
226pub struct GainOptions {
227 pub history: bool,
228 pub project_only: bool,
229 pub format: Option<String>,
230}
231
232#[derive(Debug, Default, Clone)]
234pub struct DiscoverOptions {
235 pub project: Option<String>,
236 pub limit: Option<u32>,
237 pub since: Option<u32>,
238 pub all_projects: bool,
239 pub format: Option<String>,
240}
241
242pub fn gain_args(opts: &GainOptions, config: &Config) -> Result<Vec<String>, String> {
248 let mut args = vec!["gain".to_string()];
249 if opts.history {
250 args.push("--history".to_string());
251 }
252 if opts.project_only {
253 args.push("--project".to_string());
254 }
255 if let Some(fmt) = &opts.format {
256 let fmt = validate_format(fmt, &["text", "json", "csv"])?;
257 args.push("--format".to_string());
258 args.push(fmt);
259 }
260 args.extend(config.global_flags());
261 Ok(args)
262}
263
264pub fn discover_args(opts: &DiscoverOptions, config: &Config) -> Result<Vec<String>, String> {
266 let mut args = vec!["discover".to_string()];
267 if let Some(project) = &opts.project {
268 if !project.trim().is_empty() {
269 args.push("--project".to_string());
270 args.push(project.trim().to_string());
271 }
272 }
273 if let Some(limit) = opts.limit {
274 args.push("--limit".to_string());
275 args.push(limit.to_string());
276 }
277 if let Some(since) = opts.since {
278 args.push("--since".to_string());
279 args.push(since.to_string());
280 }
281 if opts.all_projects {
282 args.push("--all".to_string());
283 }
284 if let Some(fmt) = &opts.format {
285 let fmt = validate_format(fmt, &["text", "json"])?;
286 args.push("--format".to_string());
287 args.push(fmt);
288 }
289 args.extend(config.global_flags());
290 Ok(args)
291}
292
293pub fn check_args(command: &str, config: &Config) -> Result<Vec<String>, String> {
296 if command.trim().is_empty() {
297 return Err("command must not be empty".to_string());
298 }
299 let mut args = vec!["hook".to_string(), "check".to_string()];
300 args.extend(config.global_flags());
301 args.push("--".to_string());
305 args.push(command.trim().to_string());
306 Ok(args)
307}
308
309pub fn proxy_args(argv: &[String], config: &Config) -> Result<Vec<String>, String> {
316 let cleaned: Vec<String> = argv
317 .iter()
318 .filter(|a| !a.is_empty())
319 .map(|a| a.to_string())
320 .collect();
321 if cleaned.is_empty() {
322 return Err("argv must contain at least the program to run".to_string());
323 }
324 let mut args = vec!["proxy".to_string()];
325 args.extend(config.global_flags());
326 args.push("--".to_string());
327 args.extend(cleaned);
328 Ok(args)
329}
330
331fn validate_format(value: &str, allowed: &[&str]) -> Result<String, String> {
332 let lowered = value.trim().to_ascii_lowercase();
333 if allowed.contains(&lowered.as_str()) {
334 Ok(lowered)
335 } else {
336 Err(format!(
337 "unsupported format {value:?}; expected one of {}",
338 allowed.join(", ")
339 ))
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
350 fn defaults_are_enabled_and_use_path_rtk() {
351 let config = Config::default();
352 assert!(config.enabled);
353 assert_eq!(config.rtk_bin, "rtk");
354 assert!(!config.ultra_compact);
355 assert!(!config.skip_env);
356 }
357
358 #[test]
359 fn frontmatter_overrides_defaults() {
360 let config = Config::from_markdown(
361 "---\nenabled: false\nrtk_bin: /opt/rtk/bin/rtk\nultra_compact: true\n---\n\nprose\n",
362 );
363 assert!(!config.enabled);
364 assert_eq!(config.rtk_bin, "/opt/rtk/bin/rtk");
365 assert!(config.ultra_compact);
366 assert!(!config.skip_env);
367 }
368
369 #[test]
370 fn malformed_settings_degrade_to_defaults() {
371 assert_eq!(
373 Config::from_markdown("no frontmatter here"),
374 Config::default()
375 );
376 assert_eq!(
377 Config::from_markdown("---\nnonsense: yes\nenabled: banana\n---\n"),
378 Config::default()
379 );
380 }
381
382 #[test]
383 fn env_overrides_file() {
384 let mut config = Config::from_markdown("---\nenabled: true\nrtk_bin: from-file\n---\n");
385 config.apply_env(|key| match key {
386 "RTK_BIN" => Some("from-env".to_string()),
387 "RTK_CC_DISABLE" => Some("1".to_string()),
388 _ => None,
389 });
390 assert_eq!(config.rtk_bin, "from-env");
391 assert!(!config.enabled);
392 }
393
394 #[test]
395 fn disable_flag_only_disables_when_truthy() {
396 let mut config = Config::default();
397 config.apply_env(|key| (key == "RTK_CC_DISABLE").then(|| "0".to_string()));
398 assert!(config.enabled, "RTK_CC_DISABLE=0 must not disable the hook");
399 }
400
401 #[test]
402 fn blank_rtk_bin_does_not_erase_the_default() {
403 let mut config = Config::default();
404 config.apply_env(|key| (key == "RTK_BIN").then(|| " ".to_string()));
405 assert_eq!(config.rtk_bin, "rtk");
406 }
407
408 #[test]
411 fn hook_args_are_bare_by_default() {
412 assert_eq!(hook_args(&Config::default()), vec!["hook", "claude"]);
413 }
414
415 #[test]
416 fn hook_args_carry_flags_in_stable_order() {
417 let config = Config {
418 ultra_compact: true,
419 skip_env: true,
420 ..Config::default()
421 };
422 assert_eq!(
423 hook_args(&config),
424 vec!["hook", "claude", "--ultra-compact", "--skip-env"]
425 );
426 }
427
428 const EVENT: &str = r#"{"tool_name":"Bash","tool_input":{"command":"cat README.md"}}"#;
431 const REWRITE: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":"rtk read README.md"}}}"#;
432
433 #[test]
434 fn forwards_rtk_json_verbatim() {
435 let out = delegate(EVENT, &Config::default(), |_, _| Some(REWRITE.to_string()));
436 assert_eq!(out, REWRITE);
437 }
438
439 #[test]
440 fn hands_rtk_the_original_payload() {
441 let mut seen = String::new();
442 delegate(EVENT, &Config::default(), |_, payload| {
443 seen = payload.to_string();
444 None
445 });
446 assert_eq!(seen, EVENT, "rtk must receive the event byte-for-byte");
447 }
448
449 #[test]
450 fn disabled_config_never_spawns_rtk() {
451 let config = Config {
452 enabled: false,
453 ..Config::default()
454 };
455 let out = delegate(EVENT, &config, |_, _| {
456 panic!("rtk must not be spawned when the plugin is disabled")
457 });
458 assert!(out.is_empty());
459 }
460
461 #[test]
462 fn empty_stdin_is_a_no_op() {
463 let out = delegate(" \n", &Config::default(), |_, _| {
464 panic!("rtk must not be spawned for an empty payload")
465 });
466 assert!(out.is_empty());
467 }
468
469 #[test]
470 fn missing_rtk_fails_open() {
471 assert!(delegate(EVENT, &Config::default(), |_, _| None).is_empty());
473 }
474
475 #[test]
476 fn empty_rtk_output_is_a_no_op() {
477 assert!(delegate(EVENT, &Config::default(), |_, _| Some(String::new())).is_empty());
480 }
481
482 #[test]
483 fn non_json_rtk_output_is_suppressed() {
484 let out = delegate(EVENT, &Config::default(), |_, _| {
485 Some("rtk: something went sideways\n".to_string())
486 });
487 assert!(out.is_empty(), "prose on stdout must not reach Claude Code");
488 }
489
490 #[test]
493 fn gain_args_default_to_a_bare_summary() {
494 let args = gain_args(&GainOptions::default(), &Config::default()).unwrap();
495 assert_eq!(args, vec!["gain"]);
496 }
497
498 #[test]
499 fn gain_args_accept_history_project_and_format() {
500 let opts = GainOptions {
501 history: true,
502 project_only: true,
503 format: Some("JSON".to_string()),
504 };
505 let args = gain_args(&opts, &Config::default()).unwrap();
506 assert_eq!(
507 args,
508 vec!["gain", "--history", "--project", "--format", "json"]
509 );
510 }
511
512 #[test]
513 fn gain_args_reject_unknown_formats() {
514 let opts = GainOptions {
515 format: Some("yaml".to_string()),
516 ..GainOptions::default()
517 };
518 assert!(gain_args(&opts, &Config::default()).is_err());
519 }
520
521 #[test]
522 fn gain_args_can_never_reach_reset() {
523 let opts = GainOptions {
526 history: true,
527 project_only: true,
528 format: Some("csv".to_string()),
529 };
530 let args = gain_args(&opts, &Config::default()).unwrap();
531 assert!(!args.iter().any(|a| a == "--reset"));
532 }
533
534 #[test]
535 fn discover_args_carry_every_option() {
536 let opts = DiscoverOptions {
537 project: Some(" aiplugins ".to_string()),
538 limit: Some(5),
539 since: Some(7),
540 all_projects: true,
541 format: Some("json".to_string()),
542 };
543 let args = discover_args(&opts, &Config::default()).unwrap();
544 assert_eq!(
545 args,
546 vec![
547 "discover",
548 "--project",
549 "aiplugins",
550 "--limit",
551 "5",
552 "--since",
553 "7",
554 "--all",
555 "--format",
556 "json"
557 ]
558 );
559 }
560
561 #[test]
562 fn discover_rejects_csv_which_it_does_not_support() {
563 let opts = DiscoverOptions {
566 format: Some("csv".to_string()),
567 ..DiscoverOptions::default()
568 };
569 assert!(discover_args(&opts, &Config::default()).is_err());
570 }
571
572 #[test]
573 fn check_args_pass_the_command_as_one_argument() {
574 let args = check_args("grep -rn foo src/", &Config::default()).unwrap();
575 assert_eq!(args, vec!["hook", "check", "--", "grep -rn foo src/"]);
576 }
577
578 #[test]
579 fn check_args_reject_an_empty_command() {
580 assert!(check_args(" ", &Config::default()).is_err());
581 }
582
583 #[test]
584 fn proxy_args_preserve_argv_without_splitting() {
585 let argv = vec![
586 "git".to_string(),
587 "commit".to_string(),
588 "-m".to_string(),
589 "a message; with punctuation".to_string(),
590 ];
591 let args = proxy_args(&argv, &Config::default()).unwrap();
592 assert_eq!(
593 args,
594 vec![
595 "proxy",
596 "--",
597 "git",
598 "commit",
599 "-m",
600 "a message; with punctuation"
601 ],
602 "argv elements must survive intact — no shell is involved"
603 );
604 }
605
606 #[test]
607 fn proxy_args_reject_an_empty_argv() {
608 assert!(proxy_args(&[], &Config::default()).is_err());
609 assert!(proxy_args(&["".to_string()], &Config::default()).is_err());
610 }
611
612 #[test]
613 fn global_flags_reach_the_analytics_commands_too() {
614 let config = Config {
615 ultra_compact: true,
616 ..Config::default()
617 };
618 assert!(gain_args(&GainOptions::default(), &config)
619 .unwrap()
620 .contains(&"--ultra-compact".to_string()));
621 assert!(discover_args(&DiscoverOptions::default(), &config)
622 .unwrap()
623 .contains(&"--ultra-compact".to_string()));
624 }
625}