use color_eyre::eyre::Result;
use crate::plumber::{self, Matcher, MatcherBuilder};
use super::{
cli::{Cli, CliCommand},
keymap::{self, KeyBindings},
};
pub fn load_matcher(cli: &Cli) -> Result<(Matcher, Vec<String>)> {
let mut builder = MatcherBuilder::new();
let dirs = matcher_config_dirs(cli);
let warnings = if cli.command == CliCommand::ListCommands {
plumber::load_from_dirs(&mut builder, &dirs)?;
Vec::new()
} else {
plumber::load_from_dirs_lenient(&mut builder, &dirs)
};
Ok((builder.build(), warnings))
}
pub fn reload_matcher(cli: &Cli) -> std::result::Result<Matcher, Vec<String>> {
reload_matcher_from(&matcher_config_dirs(cli))
}
fn reload_matcher_from(dirs: &[std::path::PathBuf]) -> std::result::Result<Matcher, Vec<String>> {
let mut builder = MatcherBuilder::new();
let diagnostics = plumber::load_from_dirs_lenient(&mut builder, dirs);
if diagnostics.is_empty() {
Ok(builder.build())
} else {
Err(diagnostics)
}
}
fn matcher_config_dirs(cli: &Cli) -> Vec<std::path::PathBuf> {
if cli.command == CliCommand::ListCommands && !cli.config_dirs.is_empty() {
cli.config_dirs.clone()
} else {
plumber::config_dirs(&cli.config_dirs)
}
}
pub fn load_keybindings() -> Result<KeyBindings> {
KeyBindings::load(&keymap::default_config_paths())
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use super::*;
use crate::test_support::{remove, temp_dir};
use crate::ui::keymap::{Action, Mode};
fn test_cli(command: CliCommand, config_dirs: Vec<PathBuf>) -> Cli {
Cli {
domain: "local".to_string(),
config_dirs,
service_type: None,
backend: crate::discovery::DiscoveryBackend::default(),
command,
}
}
fn write_command(dir: &std::path::Path, file: &str, name: &str, command: &str) {
fs::write(
dir.join(file),
format!(
r#"
[metadata]
name = "{name}"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "{command}"
mode = "execute"
"#
),
)
.unwrap();
}
#[test]
fn run_command_expands_default_dirs_before_extra_config_dirs() {
let extra = PathBuf::from("/tmp/kinjo-extra-commands");
let dirs = matcher_config_dirs(&test_cli(CliCommand::Run, vec![extra.clone()]));
assert!(dirs.contains(&PathBuf::from(plumber::SYSTEM_CONFIG_DIR)));
assert_eq!(dirs.last(), Some(&extra));
}
#[test]
fn list_commands_with_explicit_dirs_does_not_expand_defaults() {
let explicit = vec![
PathBuf::from("/tmp/kinjo-list-a"),
PathBuf::from("/tmp/kinjo-list-b"),
];
let dirs = matcher_config_dirs(&test_cli(CliCommand::ListCommands, explicit.clone()));
assert_eq!(dirs, explicit);
}
#[test]
fn list_commands_with_explicit_dirs_uses_them_as_overlays() {
let base = temp_dir("list-base");
let overlay = temp_dir("list-overlay");
write_command(&base, "ssh.toml", "ssh", "ssh base");
write_command(&base, "mosh.toml", "mosh", "mosh base");
write_command(&overlay, "ssh.toml", "ssh", "ssh overlay");
let (matcher, warnings) = load_matcher(&test_cli(
CliCommand::ListCommands,
vec![base.clone(), overlay.clone()],
))
.unwrap();
assert!(warnings.is_empty());
assert_eq!(matcher.command_count(), 2);
assert_eq!(matcher.commands()[0].name, "mosh");
assert_eq!(matcher.commands()[1].name, "ssh");
assert_eq!(matcher.commands()[1].action.command, "ssh overlay");
remove(&base);
remove(&overlay);
}
#[test]
fn list_commands_overlay_order_follows_command_line_order_not_placement() {
let base = temp_dir("overlay-order-base");
let overlay = temp_dir("overlay-order-overlay");
write_command(&base, "ssh.toml", "ssh", "ssh base");
write_command(&base, "mosh.toml", "mosh", "mosh base");
write_command(&overlay, "ssh.toml", "ssh", "ssh overlay");
let argv_of = |before: &[&std::path::Path], after: &[&std::path::Path]| {
let flagged = |dirs: &[&std::path::Path]| -> Vec<String> {
dirs.iter()
.flat_map(|dir| ["--config-dir".to_string(), dir.display().to_string()])
.collect()
};
let mut argv = vec!["kinjo".to_string()];
argv.extend(flagged(before));
argv.push("list-commands".to_string());
argv.extend(flagged(after));
argv
};
let (base, overlay) = (base.as_path(), overlay.as_path());
let cases = [
(argv_of(&[base, overlay], &[]), "ssh overlay"),
(argv_of(&[], &[base, overlay]), "ssh overlay"),
(argv_of(&[base], &[overlay]), "ssh overlay"),
(argv_of(&[overlay, base], &[]), "ssh base"),
(argv_of(&[], &[overlay, base]), "ssh base"),
(argv_of(&[overlay], &[base]), "ssh base"),
];
for (argv, expected) in &cases {
let cli = super::super::cli::parse_from(argv.clone()).unwrap();
let (matcher, warnings) = load_matcher(&cli).unwrap();
assert!(warnings.is_empty(), "{argv:?}");
assert_eq!(matcher.command_count(), 2, "{argv:?}");
let ssh = matcher
.commands()
.iter()
.find(|command| command.name == "ssh")
.unwrap_or_else(|| panic!("{argv:?}: no `ssh` rule"));
assert_eq!(ssh.action.command, *expected, "{argv:?}");
}
remove(base);
remove(overlay);
}
#[test]
fn run_config_dirs_parsed_from_argv_still_overlay_the_defaults() {
let extra = temp_dir("run-extra-commands");
let dirs = matcher_config_dirs(
&super::super::cli::parse_from(["kinjo", "--config-dir", extra.to_str().unwrap()])
.unwrap(),
);
assert!(dirs.contains(&PathBuf::from(plumber::SYSTEM_CONFIG_DIR)));
assert_eq!(dirs.last(), Some(&extra));
remove(&extra);
}
#[test]
fn list_commands_without_config_dirs_still_expands_the_defaults() {
let cli = super::super::cli::parse_from(["kinjo", "list-commands"]).unwrap();
let dirs = matcher_config_dirs(&cli);
assert_eq!(dirs, plumber::config_dirs(&[]));
assert!(dirs.contains(&PathBuf::from(plumber::SYSTEM_CONFIG_DIR)));
}
fn write_invalid_command(dir: &std::path::Path, file: &str) {
fs::write(
dir.join(file),
r#"
[metadata]
name = "broken"
[match.service_type]
equals = "_ssh._tcp"
[action]
command = "echo {nonexistent}"
mode = "fork"
"#,
)
.unwrap();
}
#[test]
fn startup_load_keeps_valid_commands_and_warns_about_invalid_ones() {
let dir = temp_dir("startup-lenient");
write_command(&dir, "ssh.toml", "ssh", "ssh -- {hostname}");
write_invalid_command(&dir, "broken.toml");
let (matcher, warnings) = load_matcher(&test_cli(CliCommand::Run, vec![dir.clone()]))
.expect("a bad file must not stop startup");
assert!(matcher.commands().iter().any(|c| c.name == "ssh"));
assert!(
warnings.iter().any(|w| w.contains("broken.toml")),
"{warnings:?}"
);
remove(&dir);
}
#[test]
fn reload_refuses_an_overlay_with_any_invalid_file() {
let dir = temp_dir("reload-mixed");
write_command(&dir, "ssh.toml", "ssh", "ssh -- {hostname}");
write_invalid_command(&dir, "broken.toml");
let diagnostics =
reload_matcher_from(std::slice::from_ref(&dir)).expect_err("no partial swap");
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].contains("broken.toml"), "{diagnostics:?}");
assert!(
diagnostics[0].contains("unknown service field"),
"the message keeps the reason, not just the path: {diagnostics:?}"
);
remove(&dir);
}
#[test]
fn reload_accepts_a_fully_valid_overlay_in_precedence_order() {
let base = temp_dir("reload-base");
let overlay = temp_dir("reload-overlay");
write_command(&base, "ssh.toml", "ssh", "ssh base");
write_command(&overlay, "ssh.toml", "ssh", "ssh overlay");
write_command(&overlay, "mosh.toml", "mosh", "mosh overlay");
let matcher = reload_matcher_from(&[base.clone(), overlay.clone()]).expect("all valid");
assert_eq!(matcher.command_count(), 2);
assert_eq!(matcher.commands()[0].action.command, "ssh overlay");
remove(&base);
remove(&overlay);
}
#[test]
fn a_rejected_reload_reports_every_invalid_file() {
let dir = temp_dir("reload-many-invalid");
write_command(&dir, "good.toml", "good", "true");
write_invalid_command(&dir, "a-broken.toml");
fs::write(dir.join("b-broken.toml"), "not toml at all [").unwrap();
let diagnostics = reload_matcher_from(std::slice::from_ref(&dir)).expect_err("rejected");
assert_eq!(diagnostics.len(), 2, "{diagnostics:?}");
assert!(diagnostics.iter().any(|d| d.contains("a-broken.toml")));
assert!(diagnostics.iter().any(|d| d.contains("b-broken.toml")));
remove(&dir);
}
#[test]
fn reload_ignores_directories_that_do_not_exist() {
let dir = temp_dir("reload-present");
write_command(&dir, "ssh.toml", "ssh", "ssh -- {hostname}");
let matcher =
reload_matcher_from(&[PathBuf::from("/tmp/kinjo-no-such-dir-xyz"), dir.clone()])
.expect("absent directories are simply empty layers");
assert_eq!(matcher.command_count(), 1);
remove(&dir);
}
#[test]
fn load_keybindings_falls_back_to_defaults_when_no_files_exist() {
let bindings = KeyBindings::load(&[PathBuf::from("/tmp/kinjo-no-such-keymap")]).unwrap();
assert_eq!(
bindings.resolve(
Mode::Browse,
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
)
),
Some(Action::BrowseQuit)
);
}
}