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