1use crate::cli::Cli;
2use crate::config_schema::{
3 ApplyResult, GLOBAL_SCHEMA_URL, PROJECT_SCHEMA_URL, SchemaPaths, apply_schema_header,
4 global_config_path, project_config_path, schema_paths, write_schema_files,
5};
6use crate::fs_utils::atomic_write_with_mode;
7use crate::managed_block::find_marker_index;
8use crate::schema_utils::format_schema_errors;
9use anyhow::{Result, anyhow};
10use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
11use clap_complete::generate;
12use inquire::Confirm;
13use jsonschema::validator_for;
14use serde_json::Value;
15use std::env;
16use std::fs;
17use std::io::{self, IsTerminal};
18use std::path::{Path, PathBuf};
19
20#[derive(Parser)]
21pub struct SelfArgs {
22 #[command(subcommand)]
23 pub command: SelfCommands,
24}
25
26#[derive(Subcommand)]
27pub enum SelfCommands {
28 Schema(SchemaArgs),
30 Completion(CompletionArgs),
32}
33
34#[derive(Parser)]
35pub struct SchemaArgs {
36 #[command(subcommand)]
37 pub command: SchemaCommands,
38}
39
40#[derive(Subcommand)]
41pub enum SchemaCommands {
42 Generate,
44 Apply,
46 Check,
48}
49
50#[derive(Parser)]
51pub struct CompletionArgs {
52 #[arg(long, value_enum)]
54 pub shell: CompletionShell,
55 #[arg(long)]
57 pub install: bool,
58 #[arg(long, short = 'y')]
60 pub yes: bool,
61}
62
63#[derive(ValueEnum, Debug, Clone, Copy)]
64pub enum CompletionShell {
65 #[value(name = "bash")]
66 Bash,
67 #[value(name = "zsh")]
68 Zsh,
69 #[value(name = "fish")]
70 Fish,
71 #[value(name = "powershell")]
72 PowerShell,
73 #[value(name = "elvish")]
74 Elvish,
75}
76
77impl CompletionShell {
78 fn as_clap_shell(self) -> clap_complete::Shell {
79 match self {
80 Self::Bash => clap_complete::Shell::Bash,
81 Self::Zsh => clap_complete::Shell::Zsh,
82 Self::Fish => clap_complete::Shell::Fish,
83 Self::PowerShell => clap_complete::Shell::PowerShell,
84 Self::Elvish => clap_complete::Shell::Elvish,
85 }
86 }
87}
88
89pub fn run(args: &SelfArgs) -> Result<()> {
90 match &args.command {
91 SelfCommands::Schema(schema) => run_schema(schema),
92 SelfCommands::Completion(completion) => run_completion(completion),
93 }
94}
95
96fn run_schema(args: &SchemaArgs) -> Result<()> {
97 match args.command {
98 SchemaCommands::Generate => run_generate(),
99 SchemaCommands::Apply => run_apply(),
100 SchemaCommands::Check => run_check(),
101 }
102}
103
104fn run_completion(args: &CompletionArgs) -> Result<()> {
105 if args.install {
106 install_completion(args.shell, args.yes)
107 } else {
108 generate_completion(args.shell)
109 }
110}
111
112fn generate_completion(shell: CompletionShell) -> Result<()> {
113 let mut command = Cli::command();
114 let name = command.get_name().to_string();
115 let mut stdout = io::stdout();
116 generate(shell.as_clap_shell(), &mut command, name, &mut stdout);
117 Ok(())
118}
119
120fn install_completion(shell: CompletionShell, yes: bool) -> Result<()> {
121 install_completion_with(shell, yes, confirm_install)
122}
123
124fn install_completion_with_profile_path<F>(
125 shell: CompletionShell,
126 yes: bool,
127 profile_path: &Path,
128 confirm: F,
129) -> Result<()>
130where
131 F: Fn(&Path, bool) -> Result<bool>,
132{
133 if !confirm(profile_path, yes)? {
134 println!("{}", t!("messages.operation_cancelled"));
135 return Ok(());
136 }
137 let snippet = completion_snippet(shell);
138 update_completion_block(profile_path, snippet)?;
139 println!("{}", completion_block(shell));
140 Ok(())
141}
142
143fn install_completion_with<F>(shell: CompletionShell, yes: bool, confirm: F) -> Result<()>
144where
145 F: Fn(&Path, bool) -> Result<bool>,
146{
147 let profile_path = shell_profile_path(shell)?;
148 install_completion_with_profile_path(shell, yes, &profile_path, confirm)
149}
150
151fn confirm_install(path: &Path, yes: bool) -> Result<bool> {
152 confirm_install_with(path, yes, is_interactive_terminal, |prompt, help| {
153 Confirm::new(prompt)
154 .with_default(false)
155 .with_help_message(help)
156 .prompt()
157 .map_err(|e| anyhow!(t!("errors.inquire_error", error = e)))
158 })
159}
160
161fn confirm_install_with<I, P>(path: &Path, yes: bool, is_interactive: I, prompt: P) -> Result<bool>
162where
163 I: FnOnce() -> bool,
164 P: FnOnce(&str, &str) -> Result<bool>,
165{
166 if yes {
167 return Ok(true);
168 }
169 if !is_interactive() {
170 return Err(anyhow!(t!(
171 "self.completion.non_interactive",
172 path = path.display()
173 )));
174 }
175 let prompt_text = t!("self.completion.install_prompt", path = path.display());
176 let help = t!("self.completion.install_help");
177 prompt(&prompt_text, &help)
178}
179
180fn is_interactive_terminal() -> bool {
181 io::stdin().is_terminal() && io::stdout().is_terminal()
182}
183
184fn completion_snippet(shell: CompletionShell) -> &'static str {
185 match shell {
186 CompletionShell::Bash => "source <(llman self completion --shell bash)",
187 CompletionShell::Zsh => "source <(llman self completion --shell zsh)",
188 CompletionShell::Fish => "llman self completion --shell fish | source",
189 CompletionShell::PowerShell => {
190 "llman self completion --shell powershell | Out-String | Invoke-Expression"
191 }
192 CompletionShell::Elvish => "eval (llman self completion --shell elvish)",
193 }
194}
195
196fn completion_block(shell: CompletionShell) -> String {
197 format!(
198 "{start}\n{body}\n{end}",
199 start = COMPLETION_MARKER_START,
200 body = completion_snippet(shell),
201 end = COMPLETION_MARKER_END
202 )
203}
204
205fn shell_profile_path(shell: CompletionShell) -> Result<PathBuf> {
206 let home = crate::config::home_dir()?;
207 match shell {
208 CompletionShell::Bash => Ok(bash_profile_path(&home)),
209 CompletionShell::Zsh => Ok(home.join(".zshrc")),
210 CompletionShell::Fish => Ok(home.join(".config/fish/config.fish")),
211 CompletionShell::PowerShell => match env::var("PROFILE") {
212 Ok(profile) if !profile.trim().is_empty() => {
213 resolve_powershell_profile_under_home(&home, &profile)
214 }
215 _ => Ok(home.join(".config/powershell/Microsoft.PowerShell_profile.ps1")),
216 },
217 CompletionShell::Elvish => Ok(home.join(".elvish/rc.elv")),
218 }
219}
220
221fn resolve_powershell_profile_under_home(home: &Path, profile: &str) -> Result<PathBuf> {
222 let absolute = std::path::absolute(profile.trim())
223 .map_err(|e| anyhow!(t!("self.completion.read_failed", path = profile, error = e)))?;
224 let resolved = absolute.canonicalize().unwrap_or_else(|_| absolute.clone());
225 let home_resolved = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
226 if resolved == home_resolved || resolved.starts_with(&home_resolved) {
227 return Ok(resolved);
228 }
229 Err(anyhow!(t!(
230 "self.completion.profile_outside_home",
231 path = resolved.display()
232 )))
233}
234
235fn bash_profile_path(home: &Path) -> PathBuf {
236 let bashrc = home.join(".bashrc");
237 if bashrc.exists() {
238 return bashrc;
239 }
240 let bash_profile = home.join(".bash_profile");
241 if bash_profile.exists() {
242 return bash_profile;
243 }
244 let profile = home.join(".profile");
245 if profile.exists() {
246 return profile;
247 }
248 bashrc
249}
250
251const COMPLETION_MARKER_START: &str = "# >>> llman completion >>>";
252const COMPLETION_MARKER_END: &str = "# <<< llman completion <<<";
253
254fn update_completion_block(path: &Path, body: &str) -> Result<()> {
255 let mut content = if path.exists() {
256 fs::read_to_string(path).map_err(|e| {
257 anyhow!(t!(
258 "self.completion.read_failed",
259 path = path.display(),
260 error = e
261 ))
262 })?
263 } else {
264 String::new()
265 };
266
267 if content.is_empty() {
268 content = format!(
269 "{start}\n{body}\n{end}\n",
270 start = COMPLETION_MARKER_START,
271 body = body,
272 end = COMPLETION_MARKER_END
273 );
274 } else {
275 let start_index = find_marker_index(&content, COMPLETION_MARKER_START, 0);
276 let end_index = start_index
277 .and_then(|start| {
278 find_marker_index(
279 &content,
280 COMPLETION_MARKER_END,
281 start + COMPLETION_MARKER_START.len(),
282 )
283 })
284 .or_else(|| find_marker_index(&content, COMPLETION_MARKER_END, 0));
285
286 match (start_index, end_index) {
287 (Some(start), Some(end)) => {
288 if end < start {
289 return Err(anyhow!(t!(
290 "self.completion.invalid_marker",
291 path = path.display()
292 )));
293 }
294 let before = &content[..start];
295 let after = &content[end + COMPLETION_MARKER_END.len()..];
296 content = format!(
297 "{before}{start_marker}\n{body}\n{end_marker}{after}",
298 start_marker = COMPLETION_MARKER_START,
299 end_marker = COMPLETION_MARKER_END
300 );
301 }
302 (None, None) => {
303 if !content.ends_with('\n') {
304 content.push('\n');
305 }
306 content.push_str(COMPLETION_MARKER_START);
307 content.push('\n');
308 content.push_str(body);
309 content.push('\n');
310 content.push_str(COMPLETION_MARKER_END);
311 content.push('\n');
312 }
313 _ => {
314 return Err(anyhow!(t!(
315 "self.completion.invalid_marker",
316 path = path.display()
317 )));
318 }
319 }
320 }
321
322 if let Some(parent) = path.parent()
323 && !parent.as_os_str().is_empty()
324 {
325 fs::create_dir_all(parent)?;
326 }
327 atomic_write_with_mode(path, content.as_bytes(), None).map_err(|e| {
328 anyhow!(t!(
329 "self.completion.write_failed",
330 path = path.display(),
331 error = e
332 ))
333 })?;
334 Ok(())
335}
336
337fn run_generate() -> Result<()> {
338 println!("{}", t!("self.schema.generate_start"));
339 let paths = write_schema_files()?;
340 print_written(&paths)?;
341 Ok(())
342}
343
344fn run_apply() -> Result<()> {
345 println!("{}", t!("self.schema.apply_start"));
346 let global_path = global_config_path()?;
347 let project_path = project_config_path()?;
348
349 apply_and_report(&global_path, GLOBAL_SCHEMA_URL)?;
350 apply_and_report(&project_path, PROJECT_SCHEMA_URL)?;
351 Ok(())
352}
353
354fn run_check() -> Result<()> {
355 let paths = schema_paths();
356 let global_schema = load_schema(&paths.global)?;
357 let project_schema = load_schema(&paths.project)?;
358
359 let global_path = global_config_path()?;
360 let project_path = project_config_path()?;
361 run_check_with_paths(&global_schema, &project_schema, &global_path, &project_path)
362}
363
364fn run_check_with_paths(
365 global_schema: &Value,
366 project_schema: &Value,
367 global_config_path: &Path,
368 project_config_path: &Path,
369) -> Result<()> {
370 println!("{}", t!("self.schema.check_start"));
371 fn sample_from_yaml_or_default<F>(path: &Path, default: F) -> Result<Value>
372 where
373 F: FnOnce() -> Result<Value>,
374 {
375 if !path.exists() {
376 return default();
377 }
378
379 let content = fs::read_to_string(path).map_err(|e| {
380 anyhow!(t!(
381 "self.schema.read_failed",
382 path = path.display(),
383 error = e
384 ))
385 })?;
386 let yaml: serde_json::Value = serde_saphyr::from_str(&content).map_err(|e| {
387 anyhow!(t!(
388 "self.schema.yaml_parse_failed",
389 path = path.display(),
390 error = e
391 ))
392 })?;
393 Ok(yaml)
394 }
395
396 validate_schema(
397 "llman-config",
398 global_schema,
399 sample_from_yaml_or_default(global_config_path, || {
400 serde_json::to_value(crate::config_schema::GlobalConfig::default()).map_err(Into::into)
401 })?,
402 )?;
403 validate_schema(
404 "llman-project-config",
405 project_schema,
406 sample_from_yaml_or_default(project_config_path, || {
407 serde_json::to_value(crate::config_schema::ProjectConfig::default()).map_err(Into::into)
408 })?,
409 )?;
410
411 println!("{}", t!("self.schema.check_ok"));
412 Ok(())
413}
414
415fn print_written(paths: &SchemaPaths) -> Result<()> {
416 println!(
417 "{}",
418 t!(
419 "self.schema.generate_written",
420 path = paths.global.display()
421 )
422 );
423 println!(
424 "{}",
425 t!(
426 "self.schema.generate_written",
427 path = paths.project.display()
428 )
429 );
430 Ok(())
431}
432
433fn apply_and_report(path: &std::path::Path, schema_url: &str) -> Result<()> {
434 match apply_schema_header(path, schema_url)? {
435 ApplyResult::Updated => {
436 println!("{}", t!("self.schema.apply_updated", path = path.display()))
437 }
438 ApplyResult::Unchanged => println!(
439 "{}",
440 t!("self.schema.apply_unchanged", path = path.display())
441 ),
442 ApplyResult::Missing => {
443 println!("{}", t!("self.schema.apply_skipped", path = path.display()))
444 }
445 }
446 Ok(())
447}
448
449fn load_schema(path: &std::path::Path) -> Result<Value> {
450 if !path.exists() {
451 return Err(anyhow!(t!(
452 "self.schema.check_missing",
453 path = path.display()
454 )));
455 }
456 let content = fs::read_to_string(path).map_err(|e| {
457 anyhow!(t!(
458 "self.schema.read_failed",
459 path = path.display(),
460 error = e
461 ))
462 })?;
463 serde_json::from_str(&content).map_err(|e| {
464 anyhow!(t!(
465 "self.schema.check_invalid",
466 path = path.display(),
467 error = e
468 ))
469 })
470}
471
472fn validate_schema(name: &str, schema: &Value, instance: Value) -> Result<()> {
473 let validator = validator_for(schema)
474 .map_err(|e| anyhow!(t!("self.schema.check_invalid", path = name, error = e)))?;
475 if !validator.is_valid(&instance) {
476 let first = format_schema_errors(validator.iter_errors(&instance).map(|e| e.to_string()));
477 return Err(anyhow!(t!(
478 "self.schema.check_failed",
479 name = name,
480 error = first
481 )));
482 }
483 Ok(())
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use tempfile::TempDir;
490
491 #[test]
492 fn schema_check_uses_real_yaml_when_present() {
493 let temp = TempDir::new().expect("temp dir");
494
495 crate::config_schema::ensure_global_sample_config(temp.path()).expect("sample config");
496 let config_path = temp.path().join("config.yaml");
497 let content = fs::read_to_string(&config_path).expect("read");
498 let mut yaml: serde_json::Value = serde_saphyr::from_str(&content).expect("parse yaml");
499
500 if let serde_json::Value::Object(map) = &mut yaml {
502 map.insert("version".to_string(), serde_json::Value::Bool(true));
503 } else {
504 panic!("expected mapping");
505 }
506
507 let mutated = serde_saphyr::to_string(&yaml).expect("serialize");
508 fs::write(&config_path, mutated).expect("write");
509
510 let paths = schema_paths();
511 let global_schema = load_schema(&paths.global).expect("load global schema");
512 let project_schema = load_schema(&paths.project).expect("load project schema");
513
514 let missing = temp.path().join("missing.yaml");
515 let err = run_check_with_paths(&global_schema, &project_schema, &config_path, &missing)
516 .expect_err("schema check should fail");
517 assert!(err.to_string().contains("Schema validation failed"));
518 }
519
520 #[test]
521 fn schema_check_fails_on_invalid_yaml_when_file_exists() {
522 let temp = TempDir::new().expect("temp dir");
523
524 let config_path = temp.path().join("config.yaml");
525 fs::write(&config_path, "version: [\n").expect("write invalid yaml");
526
527 let paths = schema_paths();
528 let global_schema = load_schema(&paths.global).expect("load global schema");
529 let project_schema = load_schema(&paths.project).expect("load project schema");
530
531 let missing = temp.path().join("missing.yaml");
532 let err = run_check_with_paths(&global_schema, &project_schema, &config_path, &missing)
533 .expect_err("schema check should fail");
534 assert!(err.to_string().contains("Failed to parse YAML"));
535 }
536
537 #[test]
538 fn completion_install_yes_allows_non_interactive_write() {
539 let temp_home = TempDir::new().expect("temp home");
540 let profile_path = temp_home.path().join(".bashrc");
541 install_completion_with_profile_path(
542 CompletionShell::Bash,
543 true,
544 &profile_path,
545 |path, yes| {
546 confirm_install_with(
547 path,
548 yes,
549 || false,
550 |_prompt, _help| panic!("interactive prompt should not run during tests"),
551 )
552 },
553 )
554 .expect("install should succeed");
555
556 let content = fs::read_to_string(&profile_path).expect("read profile");
557 assert!(content.contains(COMPLETION_MARKER_START));
558 assert!(content.contains(COMPLETION_MARKER_END));
559 assert!(content.contains("llman self completion --shell bash"));
560 }
561
562 #[test]
563 fn completion_install_requires_yes_in_non_interactive() {
564 let temp_home = TempDir::new().expect("temp home");
565 let profile_path = temp_home.path().join(".bashrc");
566 fs::write(&profile_path, "original\n").expect("write profile");
567
568 let err = install_completion_with_profile_path(
570 CompletionShell::Bash,
571 false,
572 &profile_path,
573 |path, yes| {
574 confirm_install_with(
575 path,
576 yes,
577 || false,
578 |_prompt, _help| panic!("interactive prompt should not run during tests"),
579 )
580 },
581 )
582 .expect_err("should error");
583 assert!(err.to_string().contains("--yes"));
584
585 let content = fs::read_to_string(&profile_path).expect("read profile");
586 assert_eq!(content, "original\n");
587 }
588
589 #[test]
590 fn confirm_install_non_interactive_skips_prompt() {
591 let path = Path::new("/tmp/fake-profile");
592 let mut prompted = false;
593
594 let err = confirm_install_with(
595 path,
596 false,
597 || false,
598 |_prompt, _help| {
599 prompted = true;
600 Ok(false)
601 },
602 )
603 .expect_err("should error");
604
605 assert!(err.to_string().contains("--yes"));
606 assert!(!prompted, "prompt callback should not be called");
607 }
608
609 #[test]
610 fn powershell_profile_outside_home_is_rejected() {
611 let temp_home = TempDir::new().expect("temp home");
612 let outside = TempDir::new().expect("outside");
613 let evil = outside.path().join("evil.ps1");
614 fs::write(&evil, "existing\n").expect("seed evil");
615
616 let mut proc = crate::test_utils::TestProcess::new();
617 proc.set_var("HOME", temp_home.path());
618 proc.set_var("PROFILE", &evil);
619
620 let err = shell_profile_path(CompletionShell::PowerShell).expect_err("reject");
621 assert!(
622 err.to_string().contains("outside the user home"),
623 "unexpected error: {err}"
624 );
625 let content = fs::read_to_string(&evil).expect("read evil");
626 assert_eq!(content, "existing\n");
627 }
628
629 #[test]
630 fn powershell_profile_under_home_is_accepted() {
631 let temp_home = TempDir::new().expect("temp home");
632 let profile = temp_home
633 .path()
634 .join(".config/powershell/Microsoft.PowerShell_profile.ps1");
635 fs::create_dir_all(profile.parent().unwrap()).expect("mkdir");
636 fs::write(&profile, "").expect("touch profile");
637
638 let mut proc = crate::test_utils::TestProcess::new();
639 proc.set_var("HOME", temp_home.path());
640 proc.set_var("PROFILE", &profile);
641
642 let resolved = shell_profile_path(CompletionShell::PowerShell).expect("accept");
643 assert_eq!(
644 resolved.canonicalize().unwrap(),
645 profile.canonicalize().unwrap()
646 );
647 }
648
649 #[test]
650 fn powershell_default_profile_when_unset() {
651 let temp_home = TempDir::new().expect("temp home");
652 let mut proc = crate::test_utils::TestProcess::new();
653 proc.set_var("HOME", temp_home.path());
654 proc.remove_var("PROFILE");
655
656 let resolved = shell_profile_path(CompletionShell::PowerShell).expect("default");
657 assert_eq!(
658 resolved,
659 temp_home
660 .path()
661 .join(".config/powershell/Microsoft.PowerShell_profile.ps1")
662 );
663 }
664}