api_testing_core/
cli_util.rs1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use nils_common::shell::quote_posix_single;
5
6use crate::{Result, env_file};
7
8pub trait WarnSink {
9 fn warn(&mut self, message: &str);
10}
11
12impl WarnSink for Vec<String> {
13 fn warn(&mut self, message: &str) {
14 self.push(message.to_string());
15 }
16}
17
18impl<'a> WarnSink for dyn Write + 'a {
19 fn warn(&mut self, message: &str) {
20 let _ = writeln!(self, "{message}");
21 }
22}
23
24pub fn trim_non_empty(s: &str) -> Option<String> {
25 let t = s.trim();
26 (!t.is_empty()).then(|| t.to_string())
27}
28
29pub fn bool_from_env<S: WarnSink + ?Sized>(
30 raw: Option<String>,
31 name: &str,
32 default: bool,
33 tool_label: Option<&str>,
34 warnings: &mut S,
35) -> bool {
36 let raw = raw.unwrap_or_default();
37 let raw = raw.trim();
38 if raw.is_empty() {
39 return default;
40 }
41 match raw.to_ascii_lowercase().as_str() {
42 "true" => true,
43 "false" => false,
44 _ => {
45 let label = tool_label.and_then(|l| (!l.trim().is_empty()).then_some(l));
46 let msg = match label {
47 Some(label) => format!(
48 "{label}: warning: {name} must be true|false (got: {raw}); treating as false"
49 ),
50 None => format!("{name} must be true|false (got: {raw}); treating as false"),
51 };
52 warnings.warn(&msg);
53 false
54 }
55 }
56}
57
58pub fn parse_u64_default(raw: Option<String>, default: u64, min: u64) -> u64 {
59 let raw = raw.unwrap_or_default();
60 let raw = raw.trim();
61 if raw.is_empty() {
62 return default;
63 }
64 if !raw.chars().all(|c| c.is_ascii_digit()) {
65 return default;
66 }
67 let Ok(v) = raw.parse::<u64>() else {
68 return default;
69 };
70 v.max(min)
71}
72
73pub fn to_env_key(s: &str) -> String {
74 env_file::normalize_env_key(s)
75}
76
77pub fn slugify(s: &str) -> String {
78 let s = s.trim().to_ascii_lowercase();
79 let mut out = String::new();
80 let mut prev_dash = false;
81 for c in s.chars() {
82 let ok = c.is_ascii_alphanumeric();
83 if ok {
84 out.push(c);
85 prev_dash = false;
86 continue;
87 }
88 if !out.is_empty() && !prev_dash {
89 out.push('-');
90 prev_dash = true;
91 }
92 }
93
94 while out.ends_with('-') {
95 out.pop();
96 }
97
98 out
99}
100
101pub fn maybe_relpath(path: &Path, base: &Path) -> String {
102 if path == base {
103 return ".".to_string();
104 }
105
106 if let Ok(stripped) = path.strip_prefix(base) {
107 let s = stripped.to_string_lossy();
108 if s.is_empty() {
109 return ".".to_string();
110 }
111 return s.to_string();
112 }
113
114 path.to_string_lossy().to_string()
115}
116
117pub fn shell_quote(s: &str) -> String {
118 quote_posix_single(s)
119}
120
121pub fn list_available_suffixes(file: &Path, prefix: &str) -> Vec<String> {
122 if !file.is_file() {
123 return Vec::new();
124 }
125
126 let Ok(content) = std::fs::read_to_string(file) else {
127 return Vec::new();
128 };
129
130 let mut out: Vec<String> = Vec::new();
131 for raw_line in content.lines() {
132 let line = raw_line.trim_end_matches('\r');
133 let mut line = line.trim();
134 if line.is_empty() || line.starts_with('#') {
135 continue;
136 }
137 if let Some(rest) = line.strip_prefix("export")
138 && rest.starts_with(char::is_whitespace)
139 {
140 line = rest.trim();
141 }
142
143 let Some((lhs, _rhs)) = line.split_once('=') else {
144 continue;
145 };
146 let key = lhs.trim();
147 let Some(suffix) = key.strip_prefix(prefix) else {
148 continue;
149 };
150 if suffix.is_empty()
151 || !suffix
152 .chars()
153 .all(|c| c.is_ascii_alphanumeric() || c == '_')
154 {
155 continue;
156 }
157 out.push(suffix.to_ascii_lowercase());
158 }
159
160 out.sort();
161 out.dedup();
162 out
163}
164
165pub fn find_git_root(start_dir: &Path) -> Option<PathBuf> {
166 let mut dir = start_dir;
167 loop {
168 if dir.join(".git").exists() {
169 return Some(dir.to_path_buf());
170 }
171 match dir.parent() {
172 Some(parent) if parent != dir => dir = parent,
173 _ => return None,
174 }
175 }
176}
177
178pub fn history_timestamp_now() -> Result<String> {
179 let format = time::format_description::parse(
180 "[year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory][offset_minute]",
181 )?;
182 let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
183 Ok(now.format(&format)?)
184}
185
186pub fn report_stamp_now() -> Result<String> {
187 let format = time::format_description::parse("[year][month][day]-[hour][minute]")?;
188 let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
189 Ok(now.format(&format)?)
190}
191
192pub fn report_date_now() -> Result<String> {
193 let format = time::format_description::parse("[year]-[month]-[day]")?;
194 let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
195 Ok(now.format(&format)?)
196}