Skip to main content

api_testing_core/
cli_util.rs

1use 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}