use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::cli::LayerArg;
use crate::config::{Layer, LoadedConfig, UserEntry};
use super::add::{entry_exists, insert_connection, set_private_permissions};
use super::user::write_user_entry;
pub fn run(cfg: &LoadedConfig, layer: Option<LayerArg>) -> Result<()> {
if matches!(layer, Some(LayerArg::Project)) {
bail!("--layer project is not allowed for 'install'; the project layer is the source");
}
let target_layer = match layer {
Some(LayerArg::System) => Layer::System,
Some(LayerArg::User) | None => Layer::User,
Some(LayerArg::Project) => unreachable!(),
};
let target_path = layer_path(target_layer)?;
let project_file = project_config_path(cfg)?;
let stdin = io::stdin();
let stdout = io::stdout();
run_impl(
&project_file,
&target_path,
&cfg.users,
&mut stdin.lock(),
&mut stdout.lock(),
)
}
fn layer_path(layer: Layer) -> Result<PathBuf> {
match layer {
Layer::System => Ok(PathBuf::from("/etc/yconn/connections.yaml")),
Layer::User => {
let base = dirs::config_dir().context("cannot determine user config directory")?;
Ok(base.join("yconn").join("connections.yaml"))
}
Layer::Project => unreachable!(),
}
}
fn project_config_path(cfg: &LoadedConfig) -> Result<PathBuf> {
let project_layer = &cfg.layers[0];
if project_layer.connection_count.is_some() {
Ok(project_layer.path.clone())
} else if let Some(ref dir) = cfg.project_dir {
Ok(dir.join("connections.yaml"))
} else {
bail!("no project config found; run 'yconn init' to create one in the current directory")
}
}
pub(crate) fn run_impl(
project_file: &Path,
target_file: &Path,
users: &HashMap<String, UserEntry>,
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<()> {
if !project_file.exists() {
bail!(
"no project config found at {}; run 'yconn init' to create one",
project_file.display()
);
}
let project_content = std::fs::read_to_string(project_file)
.with_context(|| format!("failed to read {}", project_file.display()))?;
let connection_names = extract_connection_names(&project_content);
if connection_names.is_empty() {
writeln!(output, "No connections found in {}", project_file.display())?;
return Ok(());
}
let mut target_content = if target_file.exists() {
std::fs::read_to_string(target_file)
.with_context(|| format!("failed to read {}", target_file.display()))?
} else {
String::new()
};
let missing = find_unresolved_user_keys(&project_content, users, &connection_names);
if !missing.is_empty() {
prompt_missing_user_keys(target_file, &missing, input, output)?;
if target_file.exists() {
target_content = std::fs::read_to_string(target_file)
.with_context(|| format!("failed to read {}", target_file.display()))?;
}
}
let mut modified = false;
for name in &connection_names {
let entry = extract_connection_block(&project_content, name);
if entry_exists(&target_content, name) {
write!(
output,
"Connection '{}' already exists — update? [y/N] ",
name
)?;
output.flush()?;
let mut line = String::new();
input.read_line(&mut line)?;
let answer = line.trim();
if answer == "y" || answer == "Y" {
target_content = replace_connection(&target_content, name, &entry);
writeln!(
output,
"Updating: connection {} -> {}",
name,
target_file.display()
)?;
modified = true;
} else {
writeln!(
output,
"Skipping: connection {} -> {} (already up to date)",
name,
target_file.display()
)?;
}
} else {
if target_content.is_empty() {
target_content = format!("version: 1\n\nconnections:\n {name}:\n{entry}");
} else {
target_content = insert_connection(&target_content, name, &entry);
}
writeln!(
output,
"Writing: connection {} -> {}",
name,
target_file.display()
)?;
modified = true;
}
}
if modified {
if let Some(parent) = target_file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(target_file, &target_content)
.with_context(|| format!("failed to write {}", target_file.display()))?;
set_private_permissions(target_file)?;
}
Ok(())
}
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 extract_user_field(content: &str, conn_name: &str) -> Option<String> {
let header = format!(" {conn_name}:");
let mut in_block = false;
for line in content.lines() {
if line == header || line.starts_with(&format!("{header} ")) {
in_block = true;
continue;
}
if in_block {
if !line.starts_with(" ") {
break;
}
if let Some(rest) = line.strip_prefix(" user:") {
let val = rest.trim().trim_matches('"').trim_matches('\'');
return Some(val.to_string());
}
}
}
None
}
fn find_unresolved_user_keys(
project_content: &str,
users: &HashMap<String, UserEntry>,
connection_names: &[String],
) -> Vec<(String, Vec<String>)> {
let mut unresolved: HashMap<String, Vec<String>> = HashMap::new();
for conn_name in connection_names {
if let Some(user_val) = extract_user_field(project_content, conn_name) {
for key in extract_all_template_keys(&user_val) {
if !users.contains_key(&key) {
unresolved
.entry(key.clone())
.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 prompt_missing_user_keys(
target_file: &Path,
missing: &[(String, Vec<String>)],
input: &mut dyn BufRead,
output: &mut dyn Write,
) -> Result<()> {
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();
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()
)?;
}
Ok(())
}
fn extract_connection_names(content: &str) -> Vec<String> {
let mut names = Vec::new();
let mut in_connections = false;
for line in content.lines() {
if line == "connections:" || line.starts_with("connections:") {
in_connections = true;
continue;
}
if in_connections {
if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
in_connections = false;
continue;
}
if let Some(rest) = line.strip_prefix(" ") {
if let Some(name) = rest.strip_suffix(':') {
if !name.is_empty() && !name.starts_with(' ') {
names.push(name.to_string());
}
} else if let Some(name) = rest.split_once(':').map(|(k, _)| k) {
if !name.is_empty() && !name.starts_with(' ') {
names.push(name.to_string());
}
}
}
}
}
names
}
fn extract_connection_block(content: &str, name: &str) -> String {
let header = format!(" {name}:");
let mut block = String::new();
let mut in_block = false;
for line in content.lines() {
if line == header || line.starts_with(&format!("{header} ")) {
in_block = true;
continue;
}
if in_block {
if !line.starts_with(" ") {
break;
}
block.push_str(line);
block.push('\n');
}
}
block
}
fn replace_connection(content: &str, name: &str, entry: &str) -> String {
let header = format!(" {name}:");
let mut result = String::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
if line == header || line.starts_with(&format!("{header} ")) {
result.push_str(line);
result.push('\n');
result.push_str(entry);
while let Some(next) = lines.peek() {
if next.starts_with(" ") {
lines.next();
} else {
break;
}
}
} else {
result.push_str(line);
result.push('\n');
}
}
if !content.ends_with('\n') && result.ends_with('\n') {
result.pop();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn project_yaml(connections: &[(&str, &str, &str)]) -> String {
let mut s = String::from("version: 1\n\nconnections:\n");
for (name, host, user) in connections {
s.push_str(&format!(
" {name}:\n host: {host}\n user: {user}\n auth:\n type: password\n description: \"test\"\n"
));
}
s
}
fn empty_users() -> HashMap<String, UserEntry> {
HashMap::new()
}
fn users_with(entries: &[(&str, &str)]) -> HashMap<String, UserEntry> {
entries
.iter()
.map(|(k, v)| {
(
k.to_string(),
UserEntry {
key: k.to_string(),
value: v.to_string(),
layer: crate::config::Layer::User,
source_path: PathBuf::from("/tmp/test.yaml"),
shadowed: false,
},
)
})
.collect()
}
fn run_with_stdin(
project_file: &Path,
target_file: &Path,
stdin_data: &str,
) -> (Result<()>, String) {
run_with_stdin_users(project_file, target_file, &empty_users(), stdin_data)
}
fn run_with_stdin_users(
project_file: &Path,
target_file: &Path,
users: &HashMap<String, UserEntry>,
stdin_data: &str,
) -> (Result<()>, String) {
let mut input = stdin_data.as_bytes();
let mut output = Vec::<u8>::new();
let result = run_impl(project_file, target_file, users, &mut input, &mut output);
(result, String::from_utf8(output).unwrap())
}
#[test]
fn test_new_connections_appended_and_writing_emitted() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
project_yaml(&[("alpha", "10.0.0.1", "deploy"), ("beta", "10.0.0.2", "ops")]),
)
.unwrap();
let (result, output) = run_with_stdin(&project_file, &target_file, "");
result.unwrap();
let target = fs::read_to_string(&target_file).unwrap();
assert!(target.contains("alpha:"), "alpha not found in target");
assert!(target.contains("beta:"), "beta not found in target");
let target_str = target_file.display().to_string();
assert!(
output.contains(&format!("Writing: connection alpha -> {target_str}")),
"expected 'Writing: connection alpha -> {target_str}' in output, got: {output}"
);
assert!(
output.contains(&format!("Writing: connection beta -> {target_str}")),
"expected 'Writing: connection beta -> {target_str}' in output, got: {output}"
);
}
#[test]
fn test_existing_connection_y_replaces_and_updating_emitted() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
project_yaml(&[("alpha", "10.0.0.1", "deploy")]),
)
.unwrap();
fs::write(
&target_file,
"version: 1\n\nconnections:\n alpha:\n host: old-host\n user: old-user\n auth:\n type: password\n description: \"old\"\n",
)
.unwrap();
let (result, output) = run_with_stdin(&project_file, &target_file, "y\n");
result.unwrap();
let target = fs::read_to_string(&target_file).unwrap();
assert!(
target.contains("10.0.0.1"),
"updated host not found in target"
);
assert!(
!target.contains("old-host"),
"old host should be replaced, but is still present"
);
let target_str = target_file.display().to_string();
assert!(
output.contains(&format!("Updating: connection alpha -> {target_str}")),
"expected 'Updating: connection alpha -> {target_str}' in output, got: {output}"
);
}
#[test]
fn test_existing_connection_n_skipped_and_skipping_emitted() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
project_yaml(&[("alpha", "10.0.0.1", "deploy")]),
)
.unwrap();
fs::write(
&target_file,
"version: 1\n\nconnections:\n alpha:\n host: old-host\n user: old-user\n auth:\n type: password\n description: \"old\"\n",
)
.unwrap();
let (result, output) = run_with_stdin(&project_file, &target_file, "N\n");
result.unwrap();
let target = fs::read_to_string(&target_file).unwrap();
assert!(
target.contains("old-host"),
"old host should be preserved when skipping"
);
let target_str = target_file.display().to_string();
assert!(
output.contains(&format!("Skipping: connection alpha -> {target_str} (already up to date)")),
"expected 'Skipping: connection alpha -> {target_str} (already up to date)' in output, got: {output}"
);
}
#[test]
fn test_missing_project_config_returns_error() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("nonexistent.yaml");
let target_file = dir.path().join("target.yaml");
let (result, _output) = run_with_stdin(&project_file, &target_file, "");
let err = result.unwrap_err();
assert!(
err.to_string().contains("no project config found"),
"expected error about missing project config, got: {}",
err
);
}
#[test]
fn test_layer_project_rejected() {
let layer = Some(LayerArg::Project);
assert!(matches!(layer, Some(LayerArg::Project)));
}
#[test]
fn test_extract_connection_names_finds_all() {
let yaml = project_yaml(&[
("alpha", "h1", "u1"),
("beta", "h2", "u2"),
("gamma", "h3", "u3"),
]);
let names = extract_connection_names(&yaml);
assert_eq!(names, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn test_extract_connection_names_empty_when_no_connections() {
let yaml = "version: 1\n";
let names = extract_connection_names(yaml);
assert!(names.is_empty());
}
#[test]
fn test_replace_connection_updates_body() {
let content = "version: 1\n\nconnections:\n alpha:\n host: old\n user: u\n auth:\n type: password\n description: \"d\"\n beta:\n host: bh\n user: bu\n auth:\n type: password\n description: \"d2\"\n";
let new_entry =
" host: new\n user: u\n auth:\n type: password\n description: \"d\"\n";
let result = replace_connection(content, "alpha", new_entry);
assert!(result.contains("host: new"), "new host not found");
assert!(!result.contains("host: old"), "old host still present");
assert!(result.contains("beta:"), "beta should still be present");
}
#[test]
fn test_unresolved_user_variable_triggers_prompt_and_writes_value() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nconnections:\n alpha:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"test\"\n",
).unwrap();
let (result, output) = run_with_stdin(&project_file, &target_file, "alice\n");
result.unwrap();
let target = fs::read_to_string(&target_file).unwrap();
assert!(
target.contains("users:"),
"users: section must exist in target: {target}"
);
assert!(
target.contains("t1user:"),
"t1user entry must exist in target: {target}"
);
assert!(
target.contains("alice"),
"alice value must exist in target: {target}"
);
assert!(
output.contains("Missing user variable '${t1user}' used by: alpha"),
"expected missing variable prompt in output, got: {output}"
);
assert!(
output.contains("Added user entry 't1user'"),
"expected confirmation in output, got: {output}"
);
}
#[test]
fn test_all_keys_resolved_in_users_map_skips_prompting() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nconnections:\n alpha:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"test\"\n",
).unwrap();
let users = users_with(&[("t1user", "alice")]);
let (result, output) = run_with_stdin_users(&project_file, &target_file, &users, "");
result.unwrap();
assert!(
!output.contains("Missing user variable"),
"should not prompt when key is resolved in cfg.users, got: {output}"
);
}
#[test]
fn test_project_file_users_block_resolves_without_prompt() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nusers:\n t1user: alice\n\nconnections:\n alpha:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"test\"\n",
).unwrap();
let users = users_with(&[("t1user", "alice")]);
let (result, output) = run_with_stdin_users(&project_file, &target_file, &users, "");
result.unwrap();
assert!(
!output.contains("Missing user variable"),
"should not prompt when key is defined in project file's users block, got: {output}"
);
}
#[test]
fn test_user_layer_users_resolves_without_prompt() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nconnections:\n alpha:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"test\"\n",
).unwrap();
let users = users_with(&[("t1user", "alice")]);
let (result, output) = run_with_stdin_users(&project_file, &target_file, &users, "");
result.unwrap();
assert!(
!output.contains("Missing user variable"),
"should not prompt when key is defined in user layer's users block, got: {output}"
);
}
#[test]
fn test_undefined_user_key_triggers_prompt() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nconnections:\n alpha:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"test\"\n",
).unwrap();
let (result, output) =
run_with_stdin_users(&project_file, &target_file, &empty_users(), "alice\n");
result.unwrap();
assert!(
output.contains("Missing user variable '${t1user}' used by: alpha"),
"should prompt when key is not defined in any layer, got: {output}"
);
}
#[test]
fn test_multiple_connections_same_missing_key_single_prompt() {
let dir = TempDir::new().unwrap();
let project_file = dir.path().join("project.yaml");
let target_file = dir.path().join("target.yaml");
fs::write(
&project_file,
"version: 1\n\nconnections:\n conn-a:\n host: 10.0.0.1\n user: \"${t1user}\"\n auth:\n type: password\n description: \"a\"\n conn-b:\n host: 10.0.0.2\n user: \"${t1user}\"\n auth:\n type: password\n description: \"b\"\n",
).unwrap();
let (result, output) = run_with_stdin(&project_file, &target_file, "alice\n");
result.unwrap();
assert!(
output.contains("conn-a") && output.contains("conn-b"),
"prompt should list both connections, got: {output}"
);
let prompt_count = output.matches("Missing user variable").count();
assert_eq!(
prompt_count, 1,
"should prompt only once for the same key, got {prompt_count} prompts"
);
let target = fs::read_to_string(&target_file).unwrap();
assert!(target.contains("alice"), "prompted value must be written");
}
}