use colored::Colorize;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn success(msg: &str) {
println!("{} {}", "✓".green().bold(), msg);
}
pub fn info(msg: &str) {
println!("{} {}", "→".blue().bold(), msg);
}
pub fn warn(msg: &str) {
println!("{} {}", "!".yellow().bold(), msg);
}
pub fn file_hash(path: &Path) -> Option<String> {
let content = std::fs::read(path).ok()?;
let hash = Sha256::digest(&content);
Some(format!("{:x}", hash))
}
pub fn is_user_document(path: &Path) -> bool {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
let prefixes = [
"AILOG-", "AIDEC-", "ETH-", "ADR-", "REQ-", "TES-", "INC-", "TDE-",
"SEC-", "MCARD-", "SBOM-", "DPIA-",
];
prefixes.iter().any(|p| name.starts_with(p))
}
pub fn ensure_dir(path: &Path) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
pub struct ResolvedPath {
pub path: std::path::PathBuf,
pub is_fallback: bool,
}
pub fn resolve_project_root(path: &str) -> Option<ResolvedPath> {
let target = std::path::PathBuf::from(path)
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from(path));
if target.join(".straymark").exists() {
return Some(ResolvedPath {
path: target,
is_fallback: false,
});
}
let git_root = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(&target)
.output()
.ok()
.and_then(|output| {
if output.status.success() {
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
Some(std::path::PathBuf::from(root))
} else {
None
}
});
if let Some(root) = git_root {
if root != target && root.join(".straymark").exists() {
return Some(ResolvedPath {
path: root,
is_fallback: true,
});
}
}
None
}
pub fn detect_os_locale() -> Option<String> {
let raw = std::env::var("LC_ALL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| std::env::var("LANG").ok().filter(|v| !v.is_empty()))?;
parse_posix_locale(&raw)
}
pub fn parse_posix_locale(raw: &str) -> Option<String> {
let trimmed = raw.split('.').next()?.split('@').next()?;
if trimmed.is_empty() {
return None;
}
let mut parts = trimmed.splitn(2, '_');
let lang = parts.next()?;
let territory = parts.next();
match (lang, territory) {
("zh", Some("CN")) | ("zh", Some("SG")) | ("zh", None) => Some("zh-CN".to_string()),
("zh", _) => None,
("es", _) => Some("es".to_string()),
("en", _) | ("C", _) | ("POSIX", _) => Some("en".to_string()),
_ => None,
}
}
pub fn resolve_localized_path(dir: &Path, filename: &str, lang: &str) -> PathBuf {
if lang != "en" {
let candidate = dir.join("i18n").join(lang).join(filename);
if candidate.exists() {
return candidate;
}
}
dir.join(filename)
}
pub fn visual_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
#[cfg_attr(not(any(feature = "tui", feature = "analyze")), allow(dead_code))]
pub fn truncate_visual(s: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
if visual_width(s) <= max_cols {
return s.to_string();
}
let budget = max_cols.saturating_sub(1);
let mut used = 0usize;
let mut cut_at = 0usize;
for (byte_idx, ch) in s.char_indices() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + w > budget {
cut_at = byte_idx;
break;
}
used += w;
cut_at = byte_idx + ch.len_utf8();
}
let mut out = String::with_capacity(cut_at + 3);
out.push_str(&s[..cut_at]);
out.push('…');
out
}
pub fn pad_right_visual(s: &str, cols: usize) -> String {
let w = visual_width(s);
if w >= cols {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + (cols - w));
out.push_str(s);
out.extend(std::iter::repeat_n(' ', cols - w));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn visual_width_ascii() {
assert_eq!(visual_width("hello"), 5);
assert_eq!(visual_width(""), 0);
}
#[test]
fn visual_width_accents_one_col_each() {
assert_eq!(visual_width("áéíóú"), 5);
}
#[test]
fn visual_width_cjk_two_cols_each() {
assert_eq!(visual_width("数据"), 4);
}
#[test]
fn truncate_visual_short_returns_as_is() {
assert_eq!(truncate_visual("hello", 10), "hello");
}
#[test]
fn truncate_visual_ascii_truncates_with_ellipsis() {
let out = truncate_visual("hello world", 8);
assert!(visual_width(&out) <= 8);
assert!(out.ends_with('…'));
}
#[test]
fn truncate_visual_cjk_respects_double_width() {
let out = truncate_visual("数据表格", 5);
assert!(visual_width(&out) <= 5);
assert!(std::str::from_utf8(out.as_bytes()).is_ok());
}
#[test]
fn truncate_visual_em_dash_no_panic() {
let s = "Partially mitigated — RLS is not active until middleware";
for w in [5usize, 10, 20, 67] {
let out = truncate_visual(s, w);
assert!(visual_width(&out) <= w, "{out:?} too wide for {w}");
}
}
#[test]
fn truncate_visual_zero_width() {
assert_eq!(truncate_visual("anything", 0), "");
}
#[test]
fn pad_right_visual_ascii() {
assert_eq!(pad_right_visual("hi", 5), "hi ");
}
#[test]
fn pad_right_visual_cjk_counts_two_columns() {
let out = pad_right_visual("数", 5);
assert_eq!(visual_width(&out), 5);
assert!(out.ends_with(" "));
}
#[test]
fn pad_right_visual_already_wider_returns_as_is() {
assert_eq!(pad_right_visual("hello", 3), "hello");
}
#[test]
fn resolve_localized_path_uses_translation_when_present() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let translated = dir.join("i18n").join("zh-CN");
std::fs::create_dir_all(&translated).unwrap();
std::fs::write(dir.join("FOO.md"), "english").unwrap();
std::fs::write(translated.join("FOO.md"), "中文").unwrap();
let resolved = resolve_localized_path(dir, "FOO.md", "zh-CN");
assert_eq!(resolved, translated.join("FOO.md"));
}
#[test]
fn resolve_localized_path_falls_back_to_english_when_translation_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("FOO.md"), "english").unwrap();
let resolved = resolve_localized_path(dir, "FOO.md", "zh-CN");
assert_eq!(resolved, dir.join("FOO.md"));
}
#[test]
fn parse_posix_locale_zh_cn() {
assert_eq!(parse_posix_locale("zh_CN.UTF-8"), Some("zh-CN".into()));
assert_eq!(parse_posix_locale("zh_CN"), Some("zh-CN".into()));
assert_eq!(parse_posix_locale("zh_SG.UTF-8"), Some("zh-CN".into()));
assert_eq!(parse_posix_locale("zh"), Some("zh-CN".into()));
}
#[test]
fn parse_posix_locale_traditional_chinese_unsupported() {
assert_eq!(parse_posix_locale("zh_TW.UTF-8"), None);
assert_eq!(parse_posix_locale("zh_HK.UTF-8"), None);
}
#[test]
fn parse_posix_locale_spanish_any_territory() {
assert_eq!(parse_posix_locale("es_MX.UTF-8"), Some("es".into()));
assert_eq!(parse_posix_locale("es_ES"), Some("es".into()));
assert_eq!(parse_posix_locale("es_AR.UTF-8"), Some("es".into()));
}
#[test]
fn parse_posix_locale_english_and_pseudo() {
assert_eq!(parse_posix_locale("en_US.UTF-8"), Some("en".into()));
assert_eq!(parse_posix_locale("en"), Some("en".into()));
assert_eq!(parse_posix_locale("C"), Some("en".into()));
assert_eq!(parse_posix_locale("POSIX"), Some("en".into()));
}
#[test]
fn parse_posix_locale_unsupported_returns_none() {
assert_eq!(parse_posix_locale("fr_FR.UTF-8"), None);
assert_eq!(parse_posix_locale("ja_JP.UTF-8"), None);
assert_eq!(parse_posix_locale(""), None);
}
#[test]
fn parse_posix_locale_strips_charset_and_modifier() {
assert_eq!(parse_posix_locale("es_ES@euro"), Some("es".into()));
}
#[test]
fn resolve_localized_path_for_english_skips_lookup() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path();
let stale = dir.join("i18n").join("en");
std::fs::create_dir_all(&stale).unwrap();
std::fs::write(stale.join("FOO.md"), "should not be picked").unwrap();
std::fs::write(dir.join("FOO.md"), "english").unwrap();
let resolved = resolve_localized_path(dir, "FOO.md", "en");
assert_eq!(resolved, dir.join("FOO.md"));
}
}