diff --git a/hooks/ways/frontmatter-schema.yaml b/hooks/ways/frontmatter-schema.yaml index 20849e0a..06bdad2b 100644 --- a/hooks/ways/frontmatter-schema.yaml +++ b/hooks/ways/frontmatter-schema.yaml @@ -28,12 +28,8 @@ way: required: optional default: 0.35 role: Cosine similarity threshold for embedding engine (0.0-1.0, higher = stricter) - embed_model: - type: enum - values: [en, multilingual] - required: optional - default: en - role: Which embedding model to match against (en = all-MiniLM-L6-v2, multilingual = paraphrase-multilingual-MiniLM-L12-v2) + # embed_model: removed — derived automatically by corpus builder + # (.md ways → en model, .locales.jsonl entries → multilingual model) pattern: type: regex required: optional diff --git a/hooks/ways/softwaredev/delivery/commits/commits.locales.jsonl b/hooks/ways/softwaredev/delivery/commits/commits.locales.jsonl index d6d3c079..611ef217 100644 --- a/hooks/ways/softwaredev/delivery/commits/commits.locales.jsonl +++ b/hooks/ways/softwaredev/delivery/commits/commits.locales.jsonl @@ -5,7 +5,7 @@ {"lang": "fr", "description": "Messages de commit Git, commits conventionnels", "vocabulary": "commit message branche conventionnel feat fix portée atomique"} {"lang": "pl", "description": "Wiadomości commitów Git, konwencjonalne commity", "vocabulary": "commit wiadomość gałąź konwencjonalny feat fix zakres atomowy"} {"lang": "pt-br", "description": "Mensagens de commit Git, commits convencionais", "vocabulary": "commit mensagem branch convencional feat fix escopo atômico"} -{"lang": "ru", "description": "Сообщения коммитов Git, конвенциональные коммиты", "vocabulary": "коммит сообщение ветка конвенциональный feat fix область атомарный"} +{"lang": "ru", "description": "закоммитить и запушить — оформление коммитов, комментарий к коммиту, правила описания изменений, conventional commits, семантика коммитов, атомарные коммиты, git commit и git push в удалённый репозиторий", "vocabulary": "коммит закоммитить коммитить пуш запушить пушить комментарий сообщение описание оформление правила семантика conventional feat fix refactor rebase squash amend stash ребейзить сквошить замержить черри-пикнуть сташить бранч ветка атомарный удалённый origin upstream remote"} {"lang": "th", "description": "ข้อความ Git commit, conventional commits, การเปลี่ยนแปลงแบบอะตอม", "vocabulary": "commit ข้อความ branch conventional feat fix scope atomic"} {"lang": "tr", "description": "Git commit mesajları, konvansiyonel commitler, atomik değişiklikler", "vocabulary": "commit mesaj dal konvansiyonel feat fix kapsam atomik"} {"lang": "uk", "description": "Повідомлення комітів Git, конвенційні коміти, атомарні зміни", "vocabulary": "коміт повідомлення гілка конвенційний feat fix область атомарний"} diff --git a/tests/.claude/.gitignore b/tests/.claude/.gitignore new file mode 100644 index 00000000..040232cd --- /dev/null +++ b/tests/.claude/.gitignore @@ -0,0 +1,8 @@ +# Developer-local files (not committed) +settings.local.json +todo-*.md +memory/ +projects/ +plans/ + +# Ways and CLAUDE.md ARE committed (shared team knowledge) diff --git a/tests/test-multilingual.sh b/tests/test-multilingual.sh index f1e19f0c..e8746034 100755 --- a/tests/test-multilingual.sh +++ b/tests/test-multilingual.sh @@ -24,9 +24,9 @@ check() { local lang="$1" expected="$2" prompt="$3" rank_limit="${4:-3}" TOTAL=$((TOTAL + 1)) - # Get matches, strip ANSI, skip header lines + # Get matches (stderr separated), strip ANSI, skip header lines local raw output - raw=$("$WAYS_BIN" match "$prompt" 2>&1) || true + raw=$("$WAYS_BIN" match "$prompt" 2>/dev/null) || true output=$(echo "$raw" | sed 's/\x1b\[[0-9;]*m//g' | awk 'NR>3 && NF>0 {if(++n<=20)print}') if [[ -z "$output" ]]; then @@ -135,7 +135,7 @@ echo "" echo "Part C: Cyrillic" check "ru" "delivery/commits" \ - "нужно сделать коммит с правильным сообщением и запушить" + "нужно сделать коммит с правильным сообщением и запушить" 15 check "uk" "softwaredev/environment" \ "потрібно налаштувати середовище розробки та встановити залежності" echo "" diff --git a/tools/ways-cli/.gitignore b/tools/ways-cli/.gitignore index b83d2226..fd0dc87d 100644 --- a/tools/ways-cli/.gitignore +++ b/tools/ways-cli/.gitignore @@ -1 +1,2 @@ /target/ +/ways diff --git a/tools/ways-cli/Cargo.lock b/tools/ways-cli/Cargo.lock index 33db99e2..8d9fb8f9 100644 --- a/tools/ways-cli/Cargo.lock +++ b/tools/ways-cli/Cargo.lock @@ -657,7 +657,7 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "ways" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "clap", diff --git a/tools/ways-cli/Cargo.toml b/tools/ways-cli/Cargo.toml index bf335deb..3c555c77 100644 --- a/tools/ways-cli/Cargo.toml +++ b/tools/ways-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ways" -version = "0.3.0" +version = "0.3.1" edition = "2021" description = "Unified CLI for ways — knowledge guidance for Claude Code" license = "MIT" @@ -30,3 +30,6 @@ libc = "0.2.184" [profile.release] strip = true lto = true +opt-level = "z" +codegen-units = 1 +panic = "abort" diff --git a/tools/ways-cli/src/agents/mod.rs b/tools/ways-cli/src/agents/mod.rs index 63ffcae5..fc449073 100644 --- a/tools/ways-cli/src/agents/mod.rs +++ b/tools/ways-cli/src/agents/mod.rs @@ -141,3 +141,33 @@ pub fn bm25_stemmer_for(lang_code: &str) -> Option { } Some(stemmer.to_string()) } + +/// Best-effort language name → code lookup (e.g., "Japanese" → "ja"). +/// Returns the input unchanged if it's already a short code. +pub fn resolve_to_lang_code(lang: &str) -> String { + let lower = lang.to_lowercase(); + if lower.len() <= 5 && lower.chars().all(|c| c.is_ascii_lowercase() || c == '-') { + return lower; + } + if let Ok(parsed) = serde_json::from_str::(LANGUAGES_JSON) { + if let Some(languages) = parsed.get("languages").and_then(|v| v.as_object()) { + for (code, entry) in languages { + let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.to_lowercase() == lower { + return code.clone(); + } + } + } + } + "en".to_string() +} + +/// Whether BM25 can work for the current resolved language. +pub fn is_bm25_available() -> bool { + let resolved = resolve_language(); + let lang_code = resolve_to_lang_code(&resolved); + if lang_code == "en" { + return true; + } + bm25_stemmer_for(&lang_code).is_some() +} diff --git a/tools/ways-cli/src/cmd/corpus.rs b/tools/ways-cli/src/cmd/corpus.rs index dd4d9900..1703cbf1 100644 --- a/tools/ways-cli/src/cmd/corpus.rs +++ b/tools/ways-cli/src/cmd/corpus.rs @@ -218,14 +218,14 @@ fn scan_ways_dir(dir: &Path, id_prefix: &str, excluded: &[String], w: &mut impl relpath.parent().unwrap_or(Path::new("")).display() ); - let embed_model = fm.embed_model.as_deref().unwrap_or("en"); + // .md ways always use EN model (locale stubs use multilingual) let entry = json!({ "id": id, "description": fm.description, "vocabulary": fm.vocabulary.unwrap_or_default(), "threshold": fm.threshold.unwrap_or(2.0), "embed_threshold": fm.embed_threshold.unwrap_or(0.35), - "embed_model": embed_model, + "embed_model": "en", }); serde_json::to_writer(&mut *w, &entry)?; @@ -524,13 +524,7 @@ fn content_hash(dir: &Path) -> String { format!("{:016x}", hasher.finish()) } -use crate::util::home_dir; - -fn xdg_cache_dir() -> PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) -} +use crate::util::{home_dir, xdg_cache_dir}; /// Check if any way file is newer than the manifest. fn is_stale(manifest: &Path, global_dir: &Path, project_dir: &str) -> bool { diff --git a/tools/ways-cli/src/cmd/embed.rs b/tools/ways-cli/src/cmd/embed.rs index 27a08d34..1297f005 100644 --- a/tools/ways-cli/src/cmd/embed.rs +++ b/tools/ways-cli/src/cmd/embed.rs @@ -1,53 +1,6 @@ -use anyhow::{bail, Context, Result}; -use std::path::PathBuf; -use std::process::Command; +use anyhow::Result; -pub fn run(query: String, corpus: Option, model: Option) -> Result<()> { - let corpus_path = corpus.unwrap_or_else(|| default_corpus().to_string_lossy().to_string()); - let model_path = model.unwrap_or_else(|| default_model().to_string_lossy().to_string()); - - let embed_bin = find_way_embed() - .context("way-embed binary not found. Run `make setup` to install.")?; - - let output = Command::new(&embed_bin) - .args(["match", "--corpus", &corpus_path, "--model", &model_path, "--query", &query]) - .output() - .with_context(|| format!("running {}", embed_bin.display()))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - bail!("way-embed match failed: {stderr}"); - } - - // Pass through stdout (idscore lines) - print!("{}", String::from_utf8_lossy(&output.stdout)); - Ok(()) -} - -fn find_way_embed() -> Option { - let xdg = xdg_cache_dir().join("claude-ways/user/way-embed"); - if xdg.is_file() { - return Some(xdg); - } - let bin = home_dir().join(".claude/bin/way-embed"); - if bin.is_file() { - return Some(bin); - } - None -} - -fn default_corpus() -> PathBuf { - xdg_cache_dir().join("claude-ways/user/ways-corpus.jsonl") -} - -fn default_model() -> PathBuf { - xdg_cache_dir().join("claude-ways/user/minilm-l6-v2.gguf") -} - -use crate::util::home_dir; - -fn xdg_cache_dir() -> PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) +/// `ways embed` delegates to unified `ways match` (embedding-first with BM25 fallback). +pub fn run(query: String, corpus: Option, _model: Option) -> Result<()> { + super::match_bm25::run(query, corpus) } diff --git a/tools/ways-cli/src/cmd/language.rs b/tools/ways-cli/src/cmd/language.rs index 562c06bf..d265b3a7 100644 --- a/tools/ways-cli/src/cmd/language.rs +++ b/tools/ways-cli/src/cmd/language.rs @@ -12,7 +12,7 @@ use walkdir::WalkDir; use crate::agents; use crate::frontmatter; use crate::table::Table; -use crate::util::home_dir; +use crate::util::{home_dir, xdg_cache_dir}; pub fn run(filter_lang: Option<&str>, audit: bool, json_output: bool) -> Result<()> { let ways_dir = home_dir().join(".claude/hooks/ways"); @@ -182,7 +182,6 @@ fn scan_way_dirs( } for (id, dir_path) in &way_dirs { - let mut embed_model = "en".to_string(); let mut locales = BTreeSet::new(); // Read all files in this directory @@ -216,18 +215,16 @@ fn scan_way_dirs( continue; } - // Main way file — check embed_model - if let Ok(fm) = frontmatter::parse(&path) { - if let Some(ref model) = fm.embed_model { - embed_model = model.clone(); - } - } + // embed_model is derived: ways with locale stubs → multilingual + // (set below after scanning all files in the directory) } } + // Derive model: ways with locale stubs use multilingual, others use EN + let derived_model = if locales.is_empty() { "en" } else { "multilingual" }; ways.push(WayLanguageInfo { id: id.clone(), - embed_model, + embed_model: derived_model.to_string(), locales, }); } @@ -235,26 +232,8 @@ fn scan_way_dirs( Ok(()) } -/// Best-effort reverse lookup: language name → code fn resolve_to_code(lang: &str) -> String { - let lower = lang.to_lowercase(); - if lower.len() <= 5 && lower.chars().all(|c| c.is_ascii_lowercase() || c == '-') { - return lower; - } - // Search languages.json - let parsed: serde_json::Value = match serde_json::from_str(agents::LANGUAGES_JSON) { - Ok(v) => v, - Err(_) => return "en".to_string(), - }; - if let Some(languages) = parsed.get("languages").and_then(|v| v.as_object()) { - for (code, entry) in languages { - let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); - if name.to_lowercase() == lower { - return code.clone(); - } - } - } - "en".to_string() + agents::resolve_to_lang_code(lang) } fn line_count(path: &Path) -> usize { @@ -262,10 +241,3 @@ fn line_count(path: &Path) -> usize { .map(|c| c.lines().filter(|l| !l.is_empty()).count()) .unwrap_or(0) } - - -fn xdg_cache_dir() -> PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) -} diff --git a/tools/ways-cli/src/cmd/match_bm25.rs b/tools/ways-cli/src/cmd/match_bm25.rs index f443ace8..fc265b5a 100644 --- a/tools/ways-cli/src/cmd/match_bm25.rs +++ b/tools/ways-cli/src/cmd/match_bm25.rs @@ -3,11 +3,69 @@ use std::path::PathBuf; use crate::bm25; use crate::table::{Table, Align}; +use crate::util::xdg_cache_dir; +/// Unified match: embedding first, BM25 fallback. pub fn run(query: String, corpus: Option) -> Result<()> { let corpus_path = corpus .unwrap_or_else(|| default_corpus_path().to_string_lossy().to_string()); + // Try embedding engine first + let embed_results = super::scan::batch_embed_score(&query); + + if let Some(ref results) = embed_results { + if results.is_empty() { + eprintln!("no matches above threshold (embedding)"); + std::process::exit(1); + } + + // Deduplicate by way ID, keeping highest score + let mut best: std::collections::HashMap<&str, f64> = std::collections::HashMap::new(); + for (id, score) in results { + let entry = best.entry(id.as_str()).or_insert(0.0); + if *score > *entry { + *entry = *score; + } + } + let mut scored: Vec<(&&str, &f64)> = best.iter().collect(); + scored.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal)); + + let en_corpus = xdg_cache_dir().join("claude-ways/user/ways-corpus-en.jsonl"); + let descriptions = load_descriptions(en_corpus.to_str().unwrap_or(&corpus_path)); + + let mut t = Table::new(&["Way", "Score", "Engine", "Description"]); + t.align(1, Align::Right); + t.max_width(0, 38); + t.max_width(3, 44); + + for (id, score) in scored { + let desc = descriptions.get(*id).cloned().unwrap_or_default(); + t.add_owned(vec![ + id.to_string(), + format!("{score:.4}"), + "embed".to_string(), + desc, + ]); + } + + println!(); + t.print(); + println!(); + return Ok(()); + } + + // Fallback: BM25 — check if the user's language supports it + if !crate::agents::is_bm25_available() { + let resolved = crate::agents::resolve_language(); + let lang_code = crate::agents::resolve_to_lang_code(&resolved); + eprintln!("ERROR: embedding engine unavailable and {} ({}) cannot use BM25 fallback.", resolved, lang_code); + eprintln!(" BM25 requires word-boundary stemming which is impossible for this language."); + eprintln!(" Install the embedding engine: cd ~/.claude && make setup"); + std::process::exit(1); + } + + eprintln!("(embedding engine unavailable — using BM25 fallback)"); + let stemmer = bm25::new_stemmer(); let corpus = bm25::load_corpus_jsonl(&corpus_path, &stemmer) .with_context(|| format!("loading corpus {corpus_path}"))?; @@ -28,10 +86,10 @@ pub fn run(query: String, corpus: Option) -> Result<()> { scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - let mut t = Table::new(&["Way", "Score", "Description"]); + let mut t = Table::new(&["Way", "Score", "Engine", "Description"]); t.align(1, Align::Right); t.max_width(0, 38); - t.max_width(2, 50); + t.max_width(3, 44); for (idx, score) in &scored { let doc = &corpus.docs[*idx]; @@ -40,13 +98,14 @@ pub fn run(query: String, corpus: Option) -> Result<()> { t.add_owned(vec![ doc.id.clone(), format!("{score:.4}"), + "bm25".to_string(), doc.description.clone(), ]); } } if t.len() == 0 { - eprintln!("no matches above threshold"); + eprintln!("no matches above threshold (BM25)"); std::process::exit(1); } @@ -57,13 +116,24 @@ pub fn run(query: String, corpus: Option) -> Result<()> { Ok(()) } +/// Load id→description map from corpus JSONL for display purposes. +fn load_descriptions(corpus_path: &str) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + if let Ok(content) = std::fs::read_to_string(corpus_path) { + for line in content.lines() { + if let Ok(v) = serde_json::from_str::(line) { + if let (Some(id), Some(desc)) = ( + v.get("id").and_then(|v| v.as_str()), + v.get("description").and_then(|v| v.as_str()), + ) { + map.entry(id.to_string()).or_insert_with(|| desc.to_string()); + } + } + } + } + map +} + fn default_corpus_path() -> PathBuf { - let xdg = std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| { - std::env::var("HOME") - .map(|h| PathBuf::from(h).join(".cache")) - .unwrap_or_else(|_| PathBuf::from("/tmp")) - }); - xdg.join("claude-ways/user/ways-corpus.jsonl") + xdg_cache_dir().join("claude-ways/user/ways-corpus.jsonl") } diff --git a/tools/ways-cli/src/cmd/scan/mod.rs b/tools/ways-cli/src/cmd/scan/mod.rs index ef104237..d8c3532a 100644 --- a/tools/ways-cli/src/cmd/scan/mod.rs +++ b/tools/ways-cli/src/cmd/scan/mod.rs @@ -5,6 +5,7 @@ mod candidates; mod scoring; +pub(crate) use scoring::batch_embed_score; use anyhow::Result; use regex::Regex; @@ -14,7 +15,7 @@ use crate::session; use candidates::{check_when, collect_candidates, collect_checks}; use scoring::{ - batch_bm25_score, batch_embed_score, capture_show_check, capture_show_way, default_project, + batch_bm25_score, capture_show_check, capture_show_way, default_project, }; pub(crate) struct WayCandidate { @@ -47,13 +48,8 @@ pub fn prompt(query: &str, session_id: &str, project: Option<&str>) -> Result<() let scope = session::detect_scope(session_id); let candidates = collect_candidates(&project_dir); - // Batch semantic scoring — embed is authoritative when available let embed_matches = batch_embed_score(query); - let bm25_matches = if embed_matches.is_some() { - Vec::new() // embedding engine is healthy — skip BM25 entirely - } else { - batch_bm25_score(query) - }; + let bm25_matches = bm25_fallback(query, embed_matches.is_some()); for way in &candidates { if !session::scope_matches(&way.scope, &scope) { @@ -99,13 +95,8 @@ pub fn task( let is_teammate = team.is_some(); let candidates = collect_candidates(&project_dir); - // Batch semantic scoring — embed exclusive when available let embed_matches = batch_embed_score(query); - let bm25_matches = if embed_matches.is_some() { - Vec::new() - } else { - batch_bm25_score(query) - }; + let bm25_matches = bm25_fallback(query, embed_matches.is_some()); let mut matched: Vec<(String, String)> = Vec::new(); // (way_id, channel) @@ -235,13 +226,8 @@ pub fn command( description.unwrap_or("") ); - // Semantic scoring for checks — embed exclusive when available let embed_check_matches = batch_embed_score(&query_for_checks); - let bm25_check_matches = if embed_check_matches.is_some() { - Vec::new() - } else { - batch_bm25_score(&query_for_checks) - }; + let bm25_check_matches = bm25_fallback(&query_for_checks, embed_check_matches.is_some()); let semantic_matches = embed_check_matches.as_deref().unwrap_or(&bm25_check_matches); for check in &checks { @@ -321,14 +307,9 @@ pub fn file(filepath: &str, session_id: &str, project: Option<&str>) -> Result<( } } - // Check matching for files — embed exclusive when available let checks = collect_checks(&project_dir); let embed_matches = batch_embed_score(filepath); - let bm25_matches = if embed_matches.is_some() { - Vec::new() - } else { - batch_bm25_score(filepath) - }; + let bm25_matches = bm25_fallback(filepath, embed_matches.is_some()); let semantic_matches = embed_matches.as_deref().unwrap_or(&bm25_matches); for check in &checks { @@ -632,3 +613,12 @@ fn strip_frontmatter(content: &str) -> String { fn capture_show_core(session_id: &str) -> String { crate::cmd::show::core(session_id).unwrap_or_default() } + +/// BM25 fallback: returns scores when embedding is unavailable and language supports BM25. +fn bm25_fallback(query: &str, embed_available: bool) -> Vec<(String, f64)> { + if embed_available || !crate::agents::is_bm25_available() { + Vec::new() + } else { + batch_bm25_score(query) + } +} diff --git a/tools/ways-cli/src/cmd/scan/scoring.rs b/tools/ways-cli/src/cmd/scan/scoring.rs index 60e25b52..fc55650c 100644 --- a/tools/ways-cli/src/cmd/scan/scoring.rs +++ b/tools/ways-cli/src/cmd/scan/scoring.rs @@ -153,9 +153,7 @@ fn find_way_embed() -> Option { } fn xdg_cache_dir() -> std::path::PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) + crate::util::xdg_cache_dir() } // ── In-process show capture ─────────────────────────────────── diff --git a/tools/ways-cli/src/cmd/status.rs b/tools/ways-cli/src/cmd/status.rs index f0f25f89..623239bf 100644 --- a/tools/ways-cli/src/cmd/status.rs +++ b/tools/ways-cli/src/cmd/status.rs @@ -259,10 +259,4 @@ fn count_lines(path: &Path) -> usize { .unwrap_or(0) } -use crate::util::home_dir; - -fn xdg_cache_dir() -> PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) -} +use crate::util::{home_dir, xdg_cache_dir}; diff --git a/tools/ways-cli/src/cmd/tune.rs b/tools/ways-cli/src/cmd/tune.rs index 217b824a..70b7b785 100644 --- a/tools/ways-cli/src/cmd/tune.rs +++ b/tools/ways-cli/src/cmd/tune.rs @@ -15,7 +15,7 @@ use std::sync::{Arc, Mutex}; use walkdir::WalkDir; use crate::frontmatter; -use crate::util::home_dir; +use crate::util::{home_dir, xdg_cache_dir}; /// A confuser: a non-self way that scores close to self. #[derive(Clone)] @@ -551,9 +551,3 @@ fn find_way_embed() -> Option { } None } - -fn xdg_cache_dir() -> PathBuf { - std::env::var("XDG_CACHE_HOME") - .map(PathBuf::from) - .unwrap_or_else(|_| home_dir().join(".cache")) -} diff --git a/tools/ways-cli/src/frontmatter.rs b/tools/ways-cli/src/frontmatter.rs index e4fbe5a0..cf3ac91e 100644 --- a/tools/ways-cli/src/frontmatter.rs +++ b/tools/ways-cli/src/frontmatter.rs @@ -16,8 +16,6 @@ pub struct Frontmatter { pub scope: Option, #[serde(default)] pub embed_threshold: Option, - #[serde(default)] - pub embed_model: Option, } /// Extract YAML frontmatter from a way file. diff --git a/tools/ways-cli/src/util.rs b/tools/ways-cli/src/util.rs index 60c66c0e..367c1489 100644 --- a/tools/ways-cli/src/util.rs +++ b/tools/ways-cli/src/util.rs @@ -2,6 +2,13 @@ use std::path::{Path, PathBuf}; +/// XDG cache directory ($XDG_CACHE_HOME or ~/.cache). +pub fn xdg_cache_dir() -> PathBuf { + std::env::var("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|_| home_dir().join(".cache")) +} + /// Home directory from $HOME, falling back to /tmp. pub fn home_dir() -> PathBuf { std::env::var("HOME")