tokenix 0.67.2

Symbol graphs, lexical code search, secrets scanning, output filters, and CLI hooks that save 60-90% LLM tokens
use anyhow::{anyhow, Result};
use chrono::Utc;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PreferenceScope {
    Global,
    Project,
}

pub fn global_preferences_path() -> Result<PathBuf> {
    Ok(tokenix_home()?.join("memory").join("preferences.md"))
}

pub fn project_preferences_path(repo_root: &Path) -> Result<PathBuf> {
    let name = repo_root
        .file_name()
        .map(|n| sanitize_repo_name(&n.to_string_lossy()))
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "repo".to_string());
    // Human-readable name + short project id keeps files browsable while staying
    // unique across same-named folders. Grouped under preferences/.
    Ok(tokenix_home()?.join("preferences").join(format!(
        "{}-{}.md",
        name,
        crate::store::project_id(repo_root)
    )))
}

/// Keep filesystem-safe, readable characters; collapse the rest to `_`.
fn sanitize_repo_name(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

pub fn add_preference(repo_root: &Path, scope: PreferenceScope, text: &str) -> Result<PathBuf> {
    let clean = normalize_preference_text(text)?;
    reject_sensitive_preference(&clean)?;

    let path = match scope {
        PreferenceScope::Global => global_preferences_path()?,
        PreferenceScope::Project => project_preferences_path(repo_root)?,
    };
    let header = match scope {
        PreferenceScope::Global => "# tokenix Preference Memory\n\n## Global Preferences\n\n",
        PreferenceScope::Project => "# tokenix Preference Memory\n\n## Project Preferences\n\n",
    };
    append_preference_to_file(
        &path,
        header,
        &clean,
        &Utc::now().format("%Y-%m-%d").to_string(),
    )?;
    Ok(path)
}

pub fn list_preferences(
    repo_root: &Path,
    include_global: bool,
    include_project: bool,
) -> Result<String> {
    let mut out = String::new();

    if include_global {
        out.push_str("## Global Preferences\n");
        append_scope_lines(&mut out, &global_preferences_path()?)?;
    }

    if include_project {
        if !out.is_empty() {
            out.push('\n');
        }
        out.push_str("## Project Preferences\n");
        append_scope_lines(&mut out, &project_preferences_path(repo_root)?)?;
    }

    if out.trim().is_empty() {
        return Ok("No preferences saved.".to_string());
    }
    Ok(out.trim_end().to_string())
}

pub fn remove_preference(
    repo_root: &Path,
    scope: PreferenceScope,
    query: &str,
) -> Result<(PathBuf, usize)> {
    let path = scope_path(repo_root, scope)?;
    let removed = rewrite_preference_file(&path, |line| {
        if preference_matches(line, query) {
            None
        } else {
            Some(line.to_string())
        }
    })?;
    Ok((path, removed))
}

pub fn edit_preference(
    repo_root: &Path,
    scope: PreferenceScope,
    query: &str,
    replacement: &str,
) -> Result<(PathBuf, usize)> {
    let clean = normalize_preference_text(replacement)?;
    reject_sensitive_preference(&clean)?;

    let date = Utc::now().format("%Y-%m-%d").to_string();
    let path = scope_path(repo_root, scope)?;
    let mut changed = 0usize;
    rewrite_preference_file(&path, |line| {
        if preference_matches(line, query) {
            changed += 1;
            Some(format!("- [{}] {}", date, clean))
        } else {
            Some(line.to_string())
        }
    })?;
    Ok((path, changed))
}

fn scope_path(repo_root: &Path, scope: PreferenceScope) -> Result<PathBuf> {
    match scope {
        PreferenceScope::Global => global_preferences_path(),
        PreferenceScope::Project => project_preferences_path(repo_root),
    }
}

fn tokenix_home() -> Result<PathBuf> {
    crate::store::global_dir().ok_or_else(|| anyhow!("Could not resolve home directory"))
}

fn append_scope_lines(out: &mut String, path: &Path) -> Result<()> {
    let content = fs::read_to_string(path).unwrap_or_default();
    let lines = extract_preference_lines(&content);
    if lines.is_empty() {
        out.push_str("(empty)\n");
    } else {
        for line in lines {
            out.push_str(&line);
            out.push('\n');
        }
    }
    Ok(())
}

fn append_preference_to_file(path: &Path, header: &str, text: &str, date: &str) -> Result<()> {
    let mut content = fs::read_to_string(path).unwrap_or_else(|_| header.to_string());
    if !content.ends_with('\n') {
        content.push('\n');
    }

    let normalized = normalize_for_dedupe(text);
    if extract_preference_lines(&content)
        .iter()
        .any(|line| normalize_for_dedupe(line).contains(&normalized))
    {
        return Ok(());
    }

    content.push_str(&format!("- [{}] {}\n", date, text));
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, content)?;
    Ok(())
}

fn rewrite_preference_file<F>(path: &Path, mut rewrite: F) -> Result<usize>
where
    F: FnMut(&str) -> Option<String>,
{
    let content = fs::read_to_string(path).unwrap_or_default();
    let mut changed = 0usize;
    let mut output = Vec::new();
    for line in content.lines() {
        if line.trim_start().starts_with("- ") {
            match rewrite(line.trim()) {
                Some(new_line) => {
                    if new_line != line {
                        changed += 1;
                    }
                    output.push(new_line);
                }
                None => changed += 1,
            }
        } else {
            output.push(line.to_string());
        }
    }
    if changed > 0 {
        fs::write(path, format!("{}\n", output.join("\n")))?;
    }
    Ok(changed)
}

fn extract_preference_lines(content: &str) -> Vec<String> {
    content
        .lines()
        .map(str::trim)
        .filter(|line| line.starts_with("- "))
        .map(str::to_string)
        .collect()
}

fn normalize_preference_text(text: &str) -> Result<String> {
    let clean = text.split_whitespace().collect::<Vec<_>>().join(" ");
    if clean.is_empty() {
        return Err(anyhow!("Preference text cannot be empty"));
    }
    Ok(clean)
}

fn normalize_for_dedupe(text: &str) -> String {
    text.chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                ' '
            }
        })
        .collect::<String>()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
}

fn preference_matches(line: &str, query: &str) -> bool {
    let query = normalize_for_dedupe(query);
    !query.is_empty() && normalize_for_dedupe(line).contains(&query)
}

fn reject_sensitive_preference(text: &str) -> Result<()> {
    let lower = text.to_ascii_lowercase();
    let sensitive = [
        "api_key",
        "apikey",
        "access_token",
        "auth_token",
        "bearer ",
        "client_secret",
        "password",
        "private_key",
        "secret",
        "-----begin",
    ];
    if sensitive.iter().any(|needle| lower.contains(needle))
        || crate::secrets_scan::redact_known_secrets(text).1
    {
        return Err(anyhow!(
            "Preference looks sensitive; refusing to store secrets in memory"
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_file(name: &str) -> PathBuf {
        let nonce = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        std::env::temp_dir().join(format!("tokenix-{name}-{nonce}.md"))
    }

    #[test]
    fn append_preference_creates_markdown_and_dedupes() {
        let path = temp_file("memory");
        append_preference_to_file(&path, "# H\n\n", "Prefer Biome over ESLint", "2026-05-24")
            .unwrap();
        append_preference_to_file(&path, "# H\n\n", "Prefer Biome over ESLint", "2026-05-24")
            .unwrap();

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains("# H"));
        assert_eq!(extract_preference_lines(&content).len(), 1);

        let _ = fs::remove_file(path);
    }

    #[test]
    fn project_preferences_path_is_readable_and_grouped() {
        let repo = Path::new("D:/Solutions/pessoal/tokenix");
        let path = project_preferences_path(repo).unwrap();
        assert_eq!(path.parent().unwrap().file_name().unwrap(), "preferences");
        let fname = path.file_name().unwrap().to_string_lossy();
        assert!(fname.starts_with("tokenix-"), "got {fname}");
        assert!(fname.ends_with(".md"));
    }

    #[test]
    fn sanitize_repo_name_collapses_unsafe_chars() {
        assert_eq!(sanitize_repo_name("my repo!@#"), "my_repo___");
        assert_eq!(sanitize_repo_name("ok-name_1.2"), "ok-name_1.2");
    }

    #[test]
    fn rejects_sensitive_preferences() {
        let err = reject_sensitive_preference("use api_key abc for tests").unwrap_err();
        assert!(err.to_string().contains("sensitive"));
    }

    #[test]
    fn rejects_bare_credentials_without_a_keyword() {
        // Preferences are re-injected into every future session's context, so a
        // pasted key would leak far beyond the file it is stored in.
        for text in [
            "deploy staging with AKIAIOSFODNN7EXAMPLE", // gitleaks:allow AWS docs example key
            "clone via ghp_0123456789abcdefghijklmnopqrstuvwxyzAB", // gitleaks:allow synthetic test fixture
        ] {
            assert!(
                reject_sensitive_preference(text).is_err(),
                "stored a credential: {text}"
            );
        }
        assert!(reject_sensitive_preference("prefer Biome over ESLint").is_ok());
    }

    #[test]
    fn rewrite_removes_matching_preference() {
        let path = temp_file("memory-remove");
        fs::write(
            &path,
            "# H\n\n- [2026-05-24] Prefer Biome over ESLint\n- [2026-05-24] Use cargo check\n",
        )
        .unwrap();

        let removed = rewrite_preference_file(&path, |line| {
            if preference_matches(line, "Biome") {
                None
            } else {
                Some(line.to_string())
            }
        })
        .unwrap();
        let content = fs::read_to_string(&path).unwrap();
        assert_eq!(removed, 1);
        assert!(!content.contains("Biome"));
        assert!(content.contains("cargo check"));

        let _ = fs::remove_file(path);
    }
}