Skip to main content

vtcode_commons/
utils.rs

1#![expect(
2    clippy::string_slice,
3    reason = "Cargo manifest offsets come from ASCII markers and are therefore UTF-8 boundaries."
4)]
5
6//! Generic utility functions
7
8use anyhow::{Context, Result};
9use regex::Regex;
10use sha2::{Digest, Sha256};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13/// Get current Unix timestamp in seconds
14#[inline]
15pub fn current_timestamp() -> u64 {
16    current_timestamp_result().unwrap_or(0)
17}
18
19/// Get current Unix timestamp in seconds as a fallible operation.
20#[inline]
21fn current_timestamp_result() -> Result<u64> {
22    Ok(SystemTime::now()
23        .duration_since(UNIX_EPOCH)
24        .context("System clock is before UNIX_EPOCH while generating timestamp")?
25        .as_secs())
26}
27
28/// Calculate the SHA256 hash of `content` and return it as a 64-character
29/// lowercase hex string (the standard hex encoding of the 32-byte digest).
30///
31/// Use this helper whenever a caller needs a stable, ASCII-safe fingerprint
32/// of arbitrary bytes - for example, hashing file contents for change
33/// detection, config fingerprints, or cache keys.
34pub fn calculate_sha256(content: &[u8]) -> String {
35    let mut hasher = Sha256::new();
36    hasher.update(content);
37    let digest = hasher.finalize();
38    let mut output = String::with_capacity(digest.len() * 2);
39
40    for byte in digest {
41        output.push(nibble_to_hex(byte >> 4));
42        output.push(nibble_to_hex(byte & 0x0f));
43    }
44
45    output
46}
47
48#[allow(
49    clippy::unreachable,
50    reason = "Intentional compatibility, platform, or test-only suppression."
51)]
52fn nibble_to_hex(nibble: u8) -> char {
53    match nibble {
54        0..=9 => char::from(b'0' + nibble),
55        10..=15 => char::from(b'a' + (nibble - 10)),
56        _ => unreachable!("nibble must be in 0..=15"),
57    }
58}
59
60/// Extract a string value from a simple TOML key assignment within the `[package]` section
61pub fn extract_toml_str(content: &str, key: &str) -> Option<String> {
62    // Only consider the [package] section to avoid matching other tables
63    let pkg_section = if let Some(start) = content.find("[package]") {
64        let rest = &content[start + "[package]".len()..];
65        // Stop at next section header or end
66        if let Some(_next) = rest.find('\n') {
67            &content[start..]
68        } else {
69            &content[start..]
70        }
71    } else {
72        content
73    };
74
75    // Example target: name = "vtcode"
76    let pattern = format!(r#"(?m)^\s*{}\s*=\s*"([^"]+)"\s*$"#, regex::escape(key));
77    let re = Regex::new(&pattern).ok()?;
78    re.captures(pkg_section)
79        .and_then(|caps| caps.get(1).map(|m| m.as_str().to_owned()))
80}
81
82/// Get the first meaningful section of the README/markdown as an excerpt
83pub fn extract_readme_excerpt(md: &str, max_len: usize) -> String {
84    // Take from start until we pass the first major sections or hit max_len
85    let mut excerpt = String::with_capacity(max_len.min(md.len()));
86    for line in md.lines() {
87        // Stop if we reach a deep section far into the doc
88        if excerpt.len() > max_len {
89            break;
90        }
91        excerpt.push_str(line);
92        excerpt.push('\n');
93        // Prefer stopping after an initial overview section
94        if line.trim().starts_with("## ") && excerpt.len() > (max_len / 2) {
95            break;
96        }
97    }
98    crate::formatting::truncate_byte_budget(&excerpt, max_len, "...\n")
99}
100
101/// Whether the environment variable `var_name` is set to an affirmative flag.
102///
103/// Accepts `1`, `true`, `yes`, `on`, or `debug` (case-insensitive, surrounding
104/// whitespace ignored). This is the canonical env-flag parser; do not fork the
105/// accepted-value set per crate.
106///
107/// ```
108/// # use vtcode_commons::utils::env_flag_enabled;
109/// // Unset variables are disabled.
110/// assert!(!env_flag_enabled("VTCODE_ENV_FLAG_DOCTEST_DEFINITELY_UNSET"));
111/// ```
112#[must_use]
113pub fn env_flag_enabled(var_name: &str) -> bool {
114    std::env::var(var_name).ok().is_some_and(|value| {
115        matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" | "debug")
116    })
117}
118
119/// Parse a boolean env-var value, returning `None` when unrecognized.
120///
121/// Accepted truthy values: `1`, `true`, `yes`, `on`. Accepted falsy values:
122/// `0`, `false`, `no`, `off`. Matching is case-insensitive after trimming
123/// whitespace. This is the canonical tri-state parser; do not fork the
124/// accepted-value set per crate.
125#[must_use]
126pub fn parse_bool_env_value(value: &str) -> Option<bool> {
127    match value.trim().to_ascii_lowercase().as_str() {
128        "1" | "true" | "yes" | "on" => Some(true),
129        "0" | "false" | "no" | "off" => Some(false),
130        _ => None,
131    }
132}
133
134/// Read the environment variable `name` as a boolean, returning `default`
135/// when it is unset or unrecognized.
136///
137/// ```
138/// # use vtcode_commons::utils::parse_env_bool;
139/// // Unset variables fall back to the caller-supplied default.
140/// assert!(!parse_env_bool("VTCODE_ENV_BOOL_DOCTEST_DEFINITELY_UNSET", false));
141/// assert!(parse_env_bool("VTCODE_ENV_BOOL_DOCTEST_DEFINITELY_UNSET", true));
142/// ```
143#[must_use]
144pub fn parse_env_bool(name: &str, default: bool) -> bool {
145    std::env::var(name)
146        .ok()
147        .and_then(|value| parse_bool_env_value(&value))
148        .unwrap_or(default)
149}
150
151/// Safe text replacement with validation
152pub fn safe_replace_text(content: &str, old_str: &str, new_str: &str) -> Result<String> {
153    if old_str.is_empty() {
154        return Err(anyhow::anyhow!("old_string cannot be empty"));
155    }
156
157    if !content.contains(old_str) {
158        return Err(anyhow::anyhow!("Text '{old_str}' not found in content"));
159    }
160
161    Ok(content.replace(old_str, new_str))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn readme_excerpt_does_not_split_utf8() {
170        let markdown = "你".repeat(700);
171
172        assert_eq!(extract_readme_excerpt(&markdown, 1201), format!("{}...\n", "你".repeat(400)));
173    }
174
175    #[test]
176    fn parse_bool_env_value_accepts_canonical_sets() {
177        for truthy in ["1", "true", "YES", " On ", "on"] {
178            assert_eq!(parse_bool_env_value(truthy), Some(true), "{truthy}");
179        }
180        for falsy in ["0", "false", "NO", " Off ", "off"] {
181            assert_eq!(parse_bool_env_value(falsy), Some(false), "{falsy}");
182        }
183        for unrecognized in ["", "debug", "2", "maybe"] {
184            assert_eq!(parse_bool_env_value(unrecognized), None, "{unrecognized}");
185        }
186    }
187
188    #[test]
189    fn parse_env_bool_falls_back_to_default_when_unset_or_unrecognized() {
190        assert!(!parse_env_bool("VTCODE_ENV_BOOL_TEST_DEFINITELY_UNSET", false));
191        assert!(parse_env_bool("VTCODE_ENV_BOOL_TEST_DEFINITELY_UNSET", true));
192
193        let guard = crate::env_lock::lock();
194        guard.set_var("VTCODE_ENV_BOOL_TEST_VALUE", "yes");
195        assert!(parse_env_bool("VTCODE_ENV_BOOL_TEST_VALUE", false));
196        guard.set_var("VTCODE_ENV_BOOL_TEST_VALUE", "bogus");
197        assert!(!parse_env_bool("VTCODE_ENV_BOOL_TEST_VALUE", false));
198    }
199}