1use anyhow::{anyhow, Result};
2
3use crate::models::Server;
4
5pub const BEGIN_MARKER: &str = "# BEGIN Portkey managed entries";
6pub const END_MARKER: &str = "# END Portkey managed entries";
7
8fn validate_non_empty_single_line(label: &str, value: &str) -> Result<()> {
9 if value.trim().is_empty() {
10 return Err(anyhow!("{label} cannot be empty"));
11 }
12
13 if value.chars().any(|c| c == '\n' || c == '\r') {
14 return Err(anyhow!("{label} cannot contain newlines"));
15 }
16
17 Ok(())
18}
19
20fn validate_host_alias(alias: &str) -> Result<()> {
21 validate_non_empty_single_line("Host alias", alias)?;
22
23 if alias.chars().any(char::is_whitespace) {
24 return Err(anyhow!("Host alias cannot contain whitespace"));
25 }
26
27 Ok(())
28}
29
30fn validate_server(server: &Server) -> Result<()> {
31 validate_host_alias(&server.name)?;
32 validate_non_empty_single_line("HostName", &server.host)?;
33 validate_non_empty_single_line("User", &server.username)?;
34
35 if let Some(identity_file) = server.identity_file.as_deref() {
36 if !identity_file.is_empty() {
37 validate_non_empty_single_line("IdentityFile", identity_file)?;
38 }
39 }
40
41 Ok(())
42}
43
44pub fn render_ssh_config(servers: &[Server]) -> Result<String> {
45 let mut output = String::new();
46
47 for server in servers {
48 validate_server(server)?;
49 output.push_str(&format!(
50 "Host {}\n HostName {}\n User {}\n Port {}\n",
51 server.name, server.host, server.username, server.port
52 ));
53
54 if let Some(identity_file) = server
55 .identity_file
56 .as_deref()
57 .filter(|path| !path.is_empty())
58 {
59 output.push_str(&format!(" IdentityFile {identity_file}\n"));
60 }
61
62 if server.forward_agent {
63 output.push_str(" ForwardAgent yes\n");
64 }
65
66 output.push('\n');
67 }
68
69 Ok(output)
70}
71
72pub fn render_managed_block(servers: &[Server]) -> Result<String> {
73 let config = render_ssh_config(servers)?;
74 Ok(format!("{BEGIN_MARKER}\n{config}{END_MARKER}\n"))
75}
76
77pub fn upsert_managed_block(existing: &str, managed_block: &str) -> String {
78 if let Some(begin) = existing.find(BEGIN_MARKER) {
79 if let Some(relative_end) = existing[begin..].find(END_MARKER) {
80 let end = begin + relative_end + END_MARKER.len();
81 let before = existing[..begin].trim_end();
82 let after = existing[end..].trim_start_matches(['\r', '\n']);
83
84 return match (before.is_empty(), after.is_empty()) {
85 (true, true) => managed_block.to_string(),
86 (true, false) => format!("{managed_block}\n{after}"),
87 (false, true) => format!("{before}\n\n{managed_block}"),
88 (false, false) => format!("{before}\n\n{managed_block}\n{after}"),
89 };
90 }
91 }
92
93 let existing = existing.trim_end();
94 if existing.is_empty() {
95 managed_block.to_string()
96 } else {
97 format!("{existing}\n\n{managed_block}")
98 }
99}