Skip to main content

api_testing_core/
cli_util.rs

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