use std::collections::HashMap;
use std::fs;
use std::io::{self, BufRead, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::config::{Connection, LoadedConfig};
use crate::display::Renderer;
use super::user::write_user_entry;
fn translate_name_for_ssh(name: &str) -> String {
if let Some(bracket) = name.rfind('[') {
let suffix = &name[bracket..];
if suffix.ends_with(']') && suffix.contains("..") {
return format!("{}*", &name[..bracket]);
}
}
name.to_string()
}
fn translate_host_for_ssh(host: &str) -> String {
host.replace("${name}", "%h")
}
pub fn render_ssh_config(connections: &[Connection], skip_user: bool) -> String {
let mut out = String::new();
for conn in connections {
let ssh_host = translate_name_for_ssh(&conn.name);
let ssh_hostname = translate_host_for_ssh(&conn.host);
let has_unresolved_user = !skip_user && conn.user.contains("${");
out.push_str(&format!("# description: {}\n", conn.description));
out.push_str(&format!("# auth: {}\n", conn.auth.type_label()));
if let Some(link) = &conn.link {
out.push_str(&format!("# link: {link}\n"));
}
if has_unresolved_user {
out.push_str(&format!("# user: {} (unresolved)\n", conn.user));
}
out.push_str(&format!("Host {ssh_host}\n"));
out.push_str(&format!(" HostName {ssh_hostname}\n"));
if !skip_user && !has_unresolved_user {
out.push_str(&format!(" User {}\n", conn.user));
}
if conn.port != 22 {
out.push_str(&format!(" Port {}\n", conn.port));
}
if let Some(key) = conn.auth.key() {
out.push_str(&format!(" IdentityFile {key}\n"));
if matches!(conn.auth, crate::config::Auth::Identity { .. }) {
out.push_str(" IdentitiesOnly yes\n");
}
}
out.push('\n');
}
if out.ends_with('\n') {
out.pop();
}
out
}
const INCLUDE_LINE: &str = "Include ~/.ssh/yconn-connections";
const OUTPUT_FILENAME: &str = "yconn-connections";
fn output_path(home: &Path) -> PathBuf {
home.join(".ssh").join(OUTPUT_FILENAME)
}
fn ensure_ssh_dir(home: &Path) -> Result<()> {
let ssh_dir = home.join(".ssh");
if !ssh_dir.exists() {
fs::create_dir_all(&ssh_dir)
.with_context(|| format!("failed to create {}", ssh_dir.display()))?;
fs::set_permissions(&ssh_dir, fs::Permissions::from_mode(0o700))
.with_context(|| format!("failed to set permissions on {}", ssh_dir.display()))?;
}
Ok(())
}
fn write_secure(path: &Path, content: &str) -> Result<()> {
fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
Ok(())
}
fn inject_include(home: &Path) -> Result<()> {
let config_path = home.join(".ssh").join("config");
if config_path.exists() {
let existing = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
if existing.lines().any(|l| l.trim() == INCLUDE_LINE) {
return Ok(()); }
let updated = format!("{INCLUDE_LINE}\n\n{existing}");
write_secure(&config_path, &updated)?;
} else {
write_secure(&config_path, &format!("{INCLUDE_LINE}\n"))?;
}
Ok(())
}
#[derive(Debug, PartialEq)]
struct HostBlock {
ssh_host: String,
text: String,
}
fn parse_host_blocks(content: &str) -> Vec<HostBlock> {
let mut blocks: Vec<HostBlock> = Vec::new();
let mut pending: Vec<&str> = Vec::new();
for line in content.lines() {
if line.is_empty() {
if !pending.is_empty() {
if let Some(block) = finalise_block(&pending) {
blocks.push(block);
}
pending.clear();
}
} else {
pending.push(line);
}
}
if !pending.is_empty() {
if let Some(block) = finalise_block(&pending) {
blocks.push(block);
}
}
blocks
}
fn finalise_block(lines: &[&str]) -> Option<HostBlock> {
let ssh_host = lines.iter().find_map(|l| {
let rest = l.strip_prefix("Host ")?;
if !rest.is_empty() && !rest.contains(' ') {
Some(rest.to_string())
} else {
None
}
})?;
let text = format!("{}\n\n", lines.join("\n"));
Some(HostBlock { ssh_host, text })
}
fn merge_host_blocks(existing: Vec<HostBlock>, new_blocks: Vec<HostBlock>) -> String {
use std::collections::HashMap;
let mut new_map: HashMap<String, String> = new_blocks
.iter()
.map(|b| (b.ssh_host.clone(), b.text.clone()))
.collect();
let mut merged = String::new();
for existing_block in &existing {
if let Some(new_text) = new_map.remove(&existing_block.ssh_host) {
merged.push_str(&new_text);
} else {
merged.push_str(&existing_block.text);
}
}
for new_block in &new_blocks {
if new_map.contains_key(&new_block.ssh_host) {
merged.push_str(&new_block.text);
}
}
if merged.ends_with("\n\n") {
merged.truncate(merged.len() - 1);
}
merged
}
pub(crate) fn extract_unresolved_key(value: &str) -> Option<&str> {
let start = value.find("${")?;
let rest = &value[start + 2..];
let end = rest.find('}')?;
Some(&rest[..end])
}
pub fn remove_include_line(home: &Path) -> Result<bool> {
let config_path = home.join(".ssh").join("config");
if !config_path.exists() {
return Ok(false);
}
let existing = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
if !existing.lines().any(|l| l.trim() == INCLUDE_LINE) {
return Ok(false);
}
let updated = existing
.lines()
.filter(|l| l.trim() != INCLUDE_LINE)
.collect::<Vec<_>>()
.join("\n");
let updated = updated.trim_start_matches('\n');
let updated = if existing.ends_with('\n') {
format!("{updated}\n")
} else {
updated.to_string()
};
write_secure(&config_path, &updated)?;
Ok(true)
}
fn extract_all_template_keys(value: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut rest = value;
while let Some(start) = rest.find("${") {
let after = &rest[start + 2..];
if let Some(end) = after.find('}') {
let key = &after[..end];
if !key.is_empty() {
keys.push(key.to_string());
}
rest = &after[end + 1..];
} else {
break;
}
}
keys
}
fn collect_unresolved_keys(
cfg: &LoadedConfig,
inline_overrides: &HashMap<String, String>,
) -> Vec<(String, Vec<String>)> {
let mut unresolved: HashMap<String, Vec<String>> = HashMap::new();
for conn in &cfg.connections {
for key in extract_all_template_keys(&conn.user) {
if inline_overrides.contains_key(&key) {
continue;
}
if cfg.users.contains_key(&key) {
continue;
}
if key == "user" && std::env::var("USER").is_ok() {
continue;
}
unresolved.entry(key).or_default().push(conn.name.clone());
}
}
let mut result: Vec<(String, Vec<String>)> = unresolved.into_iter().collect();
result.sort_by(|a, b| a.0.cmp(&b.0));
result
}
fn resolve_user_layer_config_path() -> Result<PathBuf> {
let base = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("cannot determine user config directory"))?;
Ok(base.join("yconn").join("connections.yaml"))
}
fn prompt_missing_keys(
target_file: &Path,
missing: &[(String, Vec<String>)],
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<Vec<(String, String)>> {
let mut prompted = Vec::new();
for (key, conn_names) in missing {
writeln!(
output,
"Missing user variable '${{{key}}}' used by: {}",
conn_names.join(", ")
)?;
write!(output, " Value for '{key}': ")?;
output.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
let value = line.trim().to_string();
if value.is_empty() {
bail!("aborted: no value provided for user variable '{key}'");
}
write_user_entry(target_file, key, &value)
.with_context(|| format!("failed to write user entry '{key}'"))?;
writeln!(
output,
" Added user entry '{key}' to {}",
target_file.display()
)?;
prompted.push((key.clone(), value));
}
Ok(prompted)
}
pub fn run_install(
cfg: &LoadedConfig,
renderer: &Renderer,
dry_run: bool,
home: &Path,
inline_overrides: &HashMap<String, String>,
skip_user: bool,
) -> Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
run_install_impl(
cfg,
renderer,
dry_run,
home,
inline_overrides,
skip_user,
&mut stdin.lock(),
&mut stdout.lock(),
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_install_impl(
cfg: &LoadedConfig,
renderer: &Renderer,
dry_run: bool,
home: &Path,
inline_overrides: &HashMap<String, String>,
skip_user: bool,
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<()> {
let mut effective_overrides = inline_overrides.clone();
let missing = collect_unresolved_keys(cfg, &effective_overrides);
if !missing.is_empty() {
let user_layer_file = resolve_user_layer_config_path()?;
let prompted = prompt_missing_keys(&user_layer_file, &missing, input, output)?;
for (key, value) in prompted {
effective_overrides.insert(key, value);
}
}
let mut connections: Vec<Connection> = cfg.connections.clone();
for conn in &mut connections {
let (expanded, warnings) = cfg.expand_user_field(&conn.user, &effective_overrides);
for w in &warnings {
let fix = extract_unresolved_key(&expanded)
.map(|key| format!(" Fix: yconn users add --user {key}:<value>"))
.unwrap_or_default();
if fix.is_empty() {
renderer.warn(w);
} else {
renderer.warn(&format!("{w}\n{fix}"));
}
}
conn.user = expanded;
}
let rendered = render_ssh_config(&connections, skip_user);
let block_count = connections.len();
let new_blocks = parse_host_blocks(&rendered);
let out_path = output_path(home);
let existing_content = if out_path.exists() {
fs::read_to_string(&out_path)
.with_context(|| format!("failed to read {}", out_path.display()))?
} else {
String::new()
};
let existing_blocks = if existing_content.is_empty() {
Vec::new()
} else {
parse_host_blocks(&existing_content)
};
let merged = merge_host_blocks(existing_blocks, new_blocks);
if dry_run {
println!("{merged}");
return Ok(());
}
ensure_ssh_dir(home)?;
write_secure(&out_path, &format!("{merged}\n"))?;
inject_include(home)?;
println!(
"Wrote {block_count} Host block(s) to {}",
out_path.display()
);
Ok(())
}
pub fn run_print(
cfg: &LoadedConfig,
renderer: &Renderer,
_home: &Path,
inline_overrides: &HashMap<String, String>,
skip_user: bool,
) -> Result<()> {
let mut connections: Vec<Connection> = cfg.connections.clone();
for conn in &mut connections {
let (expanded, warnings) = cfg.expand_user_field(&conn.user, inline_overrides);
for w in &warnings {
let fix = extract_unresolved_key(&expanded)
.map(|key| format!(" Fix: yconn users add --user {key}:<value>"))
.unwrap_or_default();
if fix.is_empty() {
renderer.warn(w);
} else {
renderer.warn(&format!("{w}\n{fix}"));
}
}
conn.user = expanded;
}
let rendered = render_ssh_config(&connections, skip_user);
println!("{rendered}");
Ok(())
}
pub fn run_uninstall(home: &Path) -> Result<()> {
let out_path = output_path(home);
if out_path.exists() {
fs::remove_file(&out_path)
.with_context(|| format!("failed to remove {}", out_path.display()))?;
println!("Removed {}", out_path.display());
} else {
println!("{} does not exist — nothing to remove", out_path.display());
}
if remove_include_line(home)? {
println!("Removed Include line from ~/.ssh/config");
} else {
println!("Include line not present in ~/.ssh/config — nothing to remove");
}
Ok(())
}
pub fn run_disable(home: &Path) -> Result<()> {
if remove_include_line(home)? {
println!("Removed Include line from ~/.ssh/config");
} else {
println!("Include line not present in ~/.ssh/config — nothing to do");
}
Ok(())
}
pub fn run_enable(home: &Path) -> Result<()> {
let config_path = home.join(".ssh").join("config");
let already_present = config_path.exists() && {
let existing = fs::read_to_string(&config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
existing.lines().any(|l| l.trim() == INCLUDE_LINE)
};
if already_present {
println!("Include line already present in ~/.ssh/config — nothing to do");
return Ok(());
}
ensure_ssh_dir(home)?;
inject_include(home)?;
println!("Added Include line to ~/.ssh/config");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Auth, Connection, Layer};
use std::path::PathBuf;
fn make_conn(
name: &str,
host: &str,
user: &str,
port: u16,
auth_type: &str,
key: Option<&str>,
) -> Connection {
let auth = match auth_type {
"key" => Auth::Key {
key: key.unwrap_or("~/.ssh/id_rsa").to_string(),
cmd: None,
},
"identity" => Auth::Identity {
key: key.unwrap_or("~/.ssh/id_rsa").to_string(),
cmd: None,
},
_ => Auth::Password,
};
Connection {
name: name.to_string(),
host: host.to_string(),
user: user.to_string(),
port,
auth,
description: format!("{name} description"),
link: None,
group: None,
layer: Layer::User,
source_path: PathBuf::from("test.yaml"),
shadowed: false,
}
}
fn make_conn_with_link(name: &str, link: &str) -> Connection {
let mut c = make_conn(name, "host.example.com", "user", 22, "password", None);
c.link = Some(link.to_string());
c
}
#[test]
fn test_key_auth_block_format() {
let conn = make_conn(
"prod-web",
"10.0.1.50",
"deploy",
22,
"key",
Some("~/.ssh/id_rsa"),
);
let out = render_ssh_config(&[conn], false);
assert!(out.contains("Host prod-web\n"), "missing Host line");
assert!(out.contains(" HostName 10.0.1.50\n"));
assert!(out.contains(" User deploy\n"));
assert!(out.contains(" IdentityFile ~/.ssh/id_rsa\n"));
assert!(!out.contains("Port"), "port 22 must be omitted");
}
#[test]
fn test_password_auth_block_no_identity_file() {
let conn = make_conn(
"staging-db",
"staging.internal",
"dbadmin",
22,
"password",
None,
);
let out = render_ssh_config(&[conn], false);
assert!(out.contains("Host staging-db\n"));
assert!(
!out.contains("IdentityFile"),
"no IdentityFile for password auth"
);
}
#[test]
fn test_identity_auth_emits_identity_file_and_identities_only() {
let conn = make_conn(
"github",
"github.com",
"git",
22,
"identity",
Some("~/.ssh/github_key"),
);
let out = render_ssh_config(&[conn], false);
assert!(out.contains("Host github\n"), "missing Host line");
assert!(out.contains(" HostName github.com\n"));
assert!(out.contains(" User git\n"));
assert!(
out.contains(" IdentityFile ~/.ssh/github_key\n"),
"identity auth must emit IdentityFile"
);
assert!(
out.contains(" IdentitiesOnly yes\n"),
"identity auth must emit IdentitiesOnly yes"
);
let id_file_pos = out.find("IdentityFile").unwrap();
let id_only_pos = out.find("IdentitiesOnly").unwrap();
assert!(
id_only_pos > id_file_pos,
"IdentitiesOnly must appear after IdentityFile"
);
}
#[test]
fn test_key_auth_does_not_emit_identities_only() {
let conn = make_conn(
"prod-web",
"10.0.1.50",
"deploy",
22,
"key",
Some("~/.ssh/id_rsa"),
);
let out = render_ssh_config(&[conn], false);
assert!(
out.contains(" IdentityFile ~/.ssh/id_rsa\n"),
"key auth must emit IdentityFile"
);
assert!(
!out.contains("IdentitiesOnly"),
"key auth must NOT emit IdentitiesOnly"
);
}
#[test]
fn test_port_22_omitted() {
let conn = make_conn("srv", "1.2.3.4", "ops", 22, "password", None);
let out = render_ssh_config(&[conn], false);
assert!(!out.contains("Port"), "port 22 must not appear");
}
#[test]
fn test_non_22_port_included() {
let conn = make_conn(
"bastion",
"bastion.example.com",
"ec2-user",
2222,
"key",
Some("~/.ssh/key"),
);
let out = render_ssh_config(&[conn], false);
assert!(out.contains(" Port 2222\n"), "custom port must appear");
}
#[test]
fn test_glob_name_rendered_as_ssh_host_pattern() {
let conn = make_conn("web-*", "${name}.corp.com", "deploy", 22, "password", None);
let out = render_ssh_config(&[conn], false);
assert!(
out.contains("Host web-*\n"),
"glob must appear as Host pattern"
);
assert!(
out.contains(" HostName %h.corp.com\n"),
"\\${{name}} must become %h"
);
assert!(!out.contains("skipped"));
}
#[test]
fn test_range_pattern_name_translated_to_glob() {
let conn = make_conn(
"server[1..10]",
"${name}.internal",
"ops",
22,
"password",
None,
);
let out = render_ssh_config(&[conn], false);
assert!(
out.contains("Host server*\n"),
"range [N..M] must become * in Host line"
);
assert!(
out.contains(" HostName %h.internal\n"),
"\\${{name}} must become %h"
);
assert!(
!out.contains("Host server[1..10]"),
"range must not appear in Host line"
);
}
#[test]
fn test_name_template_in_host_becomes_percent_h() {
let conn = make_conn("web-*", "${name}.corp.com", "deploy", 22, "password", None);
let out = render_ssh_config(&[conn], false);
assert!(out.contains(" HostName %h.corp.com\n"));
assert!(!out.contains("${name}"));
}
#[test]
fn test_literal_host_unchanged() {
let conn = make_conn(
"bastion",
"bastion.example.com",
"ec2-user",
22,
"key",
None,
);
let out = render_ssh_config(&[conn], false);
assert!(out.contains(" HostName bastion.example.com\n"));
}
#[test]
fn test_remove_include_line_removes_only_include_line() {
let tmp = tempfile::TempDir::new().unwrap();
let ssh_dir = tmp.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let config_path = ssh_dir.join("config");
fs::write(
&config_path,
format!("{INCLUDE_LINE}\n\nHost existing\n HostName 9.9.9.9\n"),
)
.unwrap();
let removed = remove_include_line(tmp.path()).unwrap();
assert!(removed, "must return true when line was present");
let result = fs::read_to_string(&config_path).unwrap();
assert!(
!result.contains(INCLUDE_LINE),
"Include line must be removed: {result}"
);
assert!(
result.contains("Host existing"),
"surrounding content must be preserved: {result}"
);
assert!(
result.contains(" HostName 9.9.9.9"),
"HostName must be preserved: {result}"
);
}
#[test]
fn test_remove_include_line_noop_when_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let ssh_dir = tmp.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let config_path = ssh_dir.join("config");
fs::write(&config_path, "Host existing\n HostName 9.9.9.9\n").unwrap();
let removed = remove_include_line(tmp.path()).unwrap();
assert!(!removed, "must return false when line was absent");
let result = fs::read_to_string(&config_path).unwrap();
assert!(
result.contains("Host existing"),
"content must be unchanged: {result}"
);
}
#[test]
fn test_idempotent_include_injection() {
let tmp = tempfile::TempDir::new().unwrap();
let ssh_dir = tmp.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let config_path = ssh_dir.join("config");
fs::write(
&config_path,
format!("{INCLUDE_LINE}\n\nHost old\n HostName 1.2.3.4\n"),
)
.unwrap();
inject_include(tmp.path()).unwrap();
let result = fs::read_to_string(&config_path).unwrap();
let count = result.lines().filter(|l| l.trim() == INCLUDE_LINE).count();
assert_eq!(count, 1, "Include must appear exactly once");
}
#[test]
fn test_include_prepended_when_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let ssh_dir = tmp.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let config_path = ssh_dir.join("config");
fs::write(&config_path, "Host existing\n HostName 9.9.9.9\n").unwrap();
inject_include(tmp.path()).unwrap();
let result = fs::read_to_string(&config_path).unwrap();
assert!(
result.starts_with(INCLUDE_LINE),
"Include must be first line"
);
assert!(
result.contains("Host existing"),
"existing content preserved"
);
}
#[test]
fn test_config_created_when_absent() {
let tmp = tempfile::TempDir::new().unwrap();
let ssh_dir = tmp.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
inject_include(tmp.path()).unwrap();
let config_path = ssh_dir.join("config");
assert!(config_path.exists(), "config file must be created");
let result = fs::read_to_string(&config_path).unwrap();
assert!(result.contains(INCLUDE_LINE));
}
#[test]
fn test_link_field_appears_in_comment() {
let conn = make_conn_with_link("srv", "https://wiki.example.com/srv");
let out = render_ssh_config(&[conn], false);
assert!(out.contains("# link: https://wiki.example.com/srv"));
assert!(!out.contains("# yconn:"));
}
fn render_expanded(
yaml: &str,
inline_overrides: &HashMap<String, String>,
skip_user: bool,
) -> (String, Vec<String>) {
use crate::config;
use tempfile::TempDir;
let user_dir = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let sys = TempDir::new().unwrap();
fs::write(user_dir.path().join("connections.yaml"), yaml).unwrap();
let cfg = config::load_impl(
cwd.path(),
Some("connections"),
false,
Some(user_dir.path()),
sys.path(),
)
.unwrap();
let mut conns: Vec<Connection> = cfg.connections.clone();
let mut all_warnings: Vec<String> = Vec::new();
for conn in &mut conns {
let (expanded, warnings) = cfg.expand_user_field(&conn.user, inline_overrides);
all_warnings.extend(warnings);
conn.user = expanded;
}
(render_ssh_config(&conns, skip_user), all_warnings)
}
#[test]
fn test_dollar_user_expanded_from_override() {
let yaml = "connections:\n srv:\n host: myhost\n user: \"${user}\"\n auth:\n type: password\n description: test\n";
let mut overrides = HashMap::new();
overrides.insert("user".to_string(), "alice".to_string());
let (out, _warnings) = render_expanded(yaml, &overrides, false);
assert!(
out.contains(" User alice\n"),
"expected 'User alice', got: {out}"
);
assert!(!out.contains("${user}"));
}
#[test]
fn test_dollar_user_unresolved_emits_comment_not_user_line() {
let conn = make_conn("srv", "myhost", "${user}", 22, "password", None);
let out = render_ssh_config(&[conn], false);
assert!(
!out.contains(" User ${user}"),
"must not render as User line: {out}"
);
assert!(
out.contains("# user: ${user} (unresolved)"),
"must render as comment: {out}"
);
}
#[test]
fn test_named_key_expanded_from_users_map() {
let yaml = "users:\n testuser: \"ops\"\nconnections:\n srv:\n host: myhost\n user: \"${testuser}\"\n auth:\n type: password\n description: test\n";
let (out, warnings) = render_expanded(yaml, &HashMap::new(), false);
assert!(
out.contains(" User ops\n"),
"expected 'User ops', got: {out}"
);
assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
}
#[test]
fn test_skip_user_omits_user_line() {
let conn = make_conn("srv", "myhost", "deploy", 22, "password", None);
let out = render_ssh_config(&[conn], true);
assert!(
!out.contains("User"),
"User line must be omitted with skip_user: {out}"
);
}
#[test]
fn test_user_override_overrides_users_map() {
let yaml = "users:\n testuser: \"ops\"\nconnections:\n srv:\n host: myhost\n user: \"${testuser}\"\n auth:\n type: password\n description: test\n";
let mut overrides = HashMap::new();
overrides.insert("testuser".to_string(), "alice".to_string());
let (out, warnings) = render_expanded(yaml, &overrides, false);
assert!(
out.contains(" User alice\n"),
"expected 'User alice', got: {out}"
);
assert!(warnings.is_empty());
}
#[test]
fn test_multiple_user_overrides_all_applied() {
let yaml = "users:\n k1: \"a\"\nconnections:\n c1:\n host: h1\n user: \"${k1}\"\n auth:\n type: password\n description: d1\n c2:\n host: h2\n user: \"${user}\"\n auth:\n type: password\n description: d2\n";
let mut overrides = HashMap::new();
overrides.insert("k1".to_string(), "carol".to_string());
overrides.insert("user".to_string(), "dave".to_string());
let (out, warnings) = render_expanded(yaml, &overrides, false);
assert!(
out.contains(" User carol\n") || out.contains(" User dave\n"),
"both overrides must be applied: {out}"
);
assert!(warnings.is_empty());
}
#[test]
fn test_unresolved_template_produces_warning() {
let yaml = "connections:\n srv:\n host: myhost\n user: \"${nokey}\"\n auth:\n type: password\n description: test\n";
let (_out, warnings) = render_expanded(yaml, &HashMap::new(), false);
assert!(
!warnings.is_empty(),
"expected warning for unresolved template"
);
assert!(
warnings[0].contains("unresolved"),
"warning must say unresolved: {}",
warnings[0]
);
}
#[test]
fn test_unresolved_template_warning_contains_fix_command() {
assert_eq!(
super::extract_unresolved_key("${t1user}"),
Some("t1user"),
"must extract key from simple token"
);
assert_eq!(
super::extract_unresolved_key("${t1user}.suffix"),
Some("t1user"),
"must extract key when followed by extra text"
);
assert_eq!(
super::extract_unresolved_key("no_template"),
None,
"must return None when no template present"
);
let key = super::extract_unresolved_key("${t1user}").unwrap();
let fix = format!(" Fix: yconn users add --user {key}:<value>");
assert!(
fix.contains("yconn users add --user t1user:<value>"),
"fix command must match expected format: {fix}"
);
}
#[test]
fn test_all_comment_fields_precede_host_line() {
let mut conn = make_conn(
"srv", "myhost", "${nokey}", 22, "password", None,
);
conn.link = Some("https://wiki.example.com/srv".to_string());
conn.description = "My server".to_string();
let out = render_ssh_config(&[conn], false);
let host_pos = out.find("Host srv\n").expect("Host line must be present");
let desc_pos = out
.find("# description:")
.expect("# description must be present");
let auth_pos = out.find("# auth:").expect("# auth must be present");
let link_pos = out.find("# link:").expect("# link must be present");
let user_pos = out
.find("# user: ${nokey} (unresolved)")
.expect("# user comment must be present");
assert!(desc_pos < host_pos, "# description must precede Host line");
assert!(auth_pos < host_pos, "# auth must precede Host line");
assert!(link_pos < host_pos, "# link must precede Host line");
assert!(
user_pos < host_pos,
"# user (unresolved) must precede Host line"
);
assert!(desc_pos < auth_pos, "# description must come before # auth");
assert!(auth_pos < link_pos, "# auth must come before # link");
assert!(
link_pos < user_pos,
"# link must come before # user comment"
);
let block_body = &out[host_pos..];
let blank_pos = block_body.find("\n\n").unwrap_or(block_body.len());
let block_interior = &block_body[..blank_pos];
let after_host_line = &block_interior["Host srv\n".len()..];
assert!(
!after_host_line.contains("\n#"),
"no # lines must appear inside the Host block: {after_host_line:?}"
);
assert!(
!after_host_line.starts_with('#'),
"first line after Host must not be a comment: {after_host_line:?}"
);
}
#[test]
fn test_parse_host_blocks_basic() {
let content = "# description: prod\n# auth: key\nHost prod-web\n HostName 10.0.1.50\n User deploy\n\n# description: db\n# auth: password\nHost staging-db\n HostName staging.internal\n\n";
let blocks = parse_host_blocks(content);
assert_eq!(blocks.len(), 2);
assert_eq!(blocks[0].ssh_host, "prod-web");
assert_eq!(blocks[1].ssh_host, "staging-db");
}
#[test]
fn test_parse_host_blocks_empty() {
assert!(parse_host_blocks("").is_empty());
}
#[test]
fn test_merge_preserves_foreign_blocks_and_replaces_matching() {
let existing_content = "# description: foreign one\n# auth: key\nHost foreign-1\n HostName f1.example.com\n\n# description: old prod\n# auth: key\nHost prod-web\n HostName 10.0.0.1\n\n# description: foreign two\n# auth: password\nHost foreign-2\n HostName f2.example.com\n\n";
let existing = parse_host_blocks(existing_content);
assert_eq!(existing.len(), 3);
let new_content =
"# description: new prod\n# auth: key\nHost prod-web\n HostName 10.0.1.50\n";
let new_blocks = parse_host_blocks(new_content);
let merged = merge_host_blocks(existing, new_blocks);
let result_blocks = parse_host_blocks(&merged);
assert_eq!(result_blocks.len(), 3, "expected 3 blocks, got: {merged}");
assert!(
merged.contains("Host foreign-1"),
"foreign-1 must be preserved: {merged}"
);
assert!(
merged.contains("Host foreign-2"),
"foreign-2 must be preserved: {merged}"
);
assert!(
merged.contains("10.0.1.50"),
"new prod-web HostName must appear: {merged}"
);
assert!(
!merged.contains("10.0.0.1"),
"old prod-web HostName must be gone: {merged}"
);
let pos_f1 = merged.find("Host foreign-1").unwrap();
let pos_prod = merged.find("Host prod-web").unwrap();
let pos_f2 = merged.find("Host foreign-2").unwrap();
assert!(pos_f1 < pos_prod, "foreign-1 must precede prod-web");
assert!(pos_prod < pos_f2, "prod-web must precede foreign-2");
}
#[test]
fn test_merge_absent_file_equals_rendered_blocks() {
let new_content = "# description: prod\n# auth: key\nHost prod-web\n HostName 10.0.1.50\n User deploy\n";
let new_blocks = parse_host_blocks(new_content);
let merged = merge_host_blocks(Vec::new(), new_blocks);
assert!(
merged.contains("Host prod-web"),
"prod-web must appear: {merged}"
);
assert!(
merged.contains(" HostName 10.0.1.50"),
"HostName must appear: {merged}"
);
}
#[test]
fn test_merge_new_blocks_appended_after_existing() {
let existing_content =
"# description: foreign\n# auth: key\nHost foreign-1\n HostName f1.example.com\n\n";
let existing = parse_host_blocks(existing_content);
let new_content =
"# description: prod\n# auth: key\nHost prod-web\n HostName 10.0.1.50\n";
let new_blocks = parse_host_blocks(new_content);
let merged = merge_host_blocks(existing, new_blocks);
let pos_foreign = merged.find("Host foreign-1").unwrap();
let pos_prod = merged.find("Host prod-web").unwrap();
assert!(
pos_foreign < pos_prod,
"existing foreign block must precede newly appended block"
);
}
#[test]
fn test_skip_user_resolved_no_comment_inside_host_block() {
let conn = make_conn("srv", "myhost", "deploy", 22, "key", Some("~/.ssh/id_rsa"));
let out = render_ssh_config(&[conn], true);
let host_pos = out.find("Host srv\n").expect("Host line must be present");
let block_body = &out[host_pos..];
let blank_pos = block_body.find("\n\n").unwrap_or(block_body.len());
let block_interior = &block_body[..blank_pos];
let after_host_line = &block_interior["Host srv\n".len()..];
assert!(
!after_host_line.contains("\n#"),
"no # lines must appear inside the Host block with skip_user=true: {after_host_line:?}"
);
assert!(
!after_host_line.starts_with('#'),
"first line after Host must not be a comment: {after_host_line:?}"
);
assert!(
!out.contains("User "),
"User line must be absent with skip_user=true"
);
}
fn load_cfg(yaml: &str) -> (crate::config::LoadedConfig, tempfile::TempDir) {
use crate::config;
use tempfile::TempDir;
let user_dir = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let sys = TempDir::new().unwrap();
fs::write(user_dir.path().join("connections.yaml"), yaml).unwrap();
let cfg = config::load_impl(
cwd.path(),
Some("connections"),
false,
Some(user_dir.path()),
sys.path(),
)
.unwrap();
(cfg, user_dir)
}
#[test]
fn test_run_install_impl_unresolved_key_prompts_and_resolves() {
let yaml = "connections:\n srv:\n host: myhost\n user: \"${t1user}\"\n auth:\n type: password\n description: test\n";
let (cfg, _user_dir) = load_cfg(yaml);
let home = tempfile::TempDir::new().unwrap();
let ssh_dir = home.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let renderer = crate::display::Renderer::new(false);
let mut input = "alice\n".as_bytes();
let mut output = Vec::<u8>::new();
let xdg_dir = tempfile::TempDir::new().unwrap();
std::env::set_var("XDG_CONFIG_HOME", xdg_dir.path());
let result = super::run_install_impl(
&cfg,
&renderer,
false,
home.path(),
&HashMap::new(),
false,
&mut input,
&mut output,
);
result.unwrap();
let output_str = String::from_utf8(output).unwrap();
assert!(
output_str.contains("Missing user variable '${t1user}' used by: srv"),
"expected prompt for missing variable, got: {output_str}"
);
assert!(
output_str.contains("Added user entry 't1user'"),
"expected confirmation, got: {output_str}"
);
let host_blocks =
fs::read_to_string(home.path().join(".ssh").join("yconn-connections")).unwrap();
assert!(
host_blocks.contains("User alice"),
"expected 'User alice' in Host block, got: {host_blocks}"
);
}
#[test]
fn test_run_install_impl_resolved_keys_no_prompt() {
let yaml = "users:\n t1user: \"bob\"\nconnections:\n srv:\n host: myhost\n user: \"${t1user}\"\n auth:\n type: password\n description: test\n";
let (cfg, _user_dir) = load_cfg(yaml);
let home = tempfile::TempDir::new().unwrap();
let ssh_dir = home.path().join(".ssh");
fs::create_dir_all(&ssh_dir).unwrap();
let renderer = crate::display::Renderer::new(false);
let mut input = "".as_bytes();
let mut output = Vec::<u8>::new();
let result = super::run_install_impl(
&cfg,
&renderer,
false,
home.path(),
&HashMap::new(),
false,
&mut input,
&mut output,
);
result.unwrap();
let output_str = String::from_utf8(output).unwrap();
assert!(
!output_str.contains("Missing user variable"),
"should not prompt when all keys are resolved, got: {output_str}"
);
let host_blocks =
fs::read_to_string(home.path().join(".ssh").join("yconn-connections")).unwrap();
assert!(
host_blocks.contains("User bob"),
"expected 'User bob' in Host block, got: {host_blocks}"
);
}
}