collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Project trust system.
//!
//! When a user enters a project directory for the first time (no existing trust
//! record), they are prompted to choose a trust level. This controls which
//! tools the agent may invoke.

use std::io::Write;
use std::path::PathBuf;

use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::terminal;

/// Trust level for a project directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrustLevel {
    /// Full access — all tools enabled.
    Full,
    /// Read-only — no file writes, no bash, no git_patch.
    ReadOnly,
    /// Untrusted — no tools at all (ask mode only).
    Untrusted,
}

impl TrustLevel {
    pub fn label(self) -> &'static str {
        match self {
            TrustLevel::Full => "full",
            TrustLevel::ReadOnly => "read-only",
            TrustLevel::Untrusted => "untrusted",
        }
    }
}

impl std::fmt::Display for TrustLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.label())
    }
}

/// Return the path to the trust record for a given working directory.
fn trust_file(working_dir: &str) -> PathBuf {
    crate::config::project_data_dir(working_dir).join("trust.json")
}

/// Load the saved trust level for a project, if any.
pub fn load_trust(working_dir: &str) -> Option<TrustLevel> {
    let path = trust_file(working_dir);
    let content = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&content).ok()
}

/// Save the trust level for a project.
pub fn save_trust(working_dir: &str, level: TrustLevel) {
    let path = trust_file(working_dir);
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(json) = serde_json::to_string(&level) {
        let _ = std::fs::write(path, json);
    }
}

// ── Interactive selector ────────────────────────────────────────────────────

const OPTIONS: &[(TrustLevel, &str, &str)] = &[
    (TrustLevel::Full, "Trust folder", "full tool access"),
    (
        TrustLevel::ReadOnly,
        "Read-only access",
        "write tools disabled",
    ),
    (TrustLevel::Untrusted, "Don't trust", "all tools disabled"),
];

/// Prompt the user to choose a trust level with arrow-key navigation.
///
/// Runs **before** TUI alternate screen, using raw mode temporarily.
pub fn prompt_trust(working_dir: &str) -> TrustLevel {
    let folder_name = std::path::Path::new(working_dir)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(working_dir)
        .to_string();

    // Print header (normal mode, before raw)
    let mut stderr = std::io::stderr();
    let _ = writeln!(stderr);
    let _ = writeln!(
        stderr,
        "  \x1b[1mDo you trust the files in this folder?\x1b[0m"
    );
    let _ = writeln!(stderr);
    let _ = writeln!(
        stderr,
        "  Trusting a folder allows collet to load its local configurations,"
    );
    let _ = writeln!(
        stderr,
        "  execute shell commands, and modify files on your behalf."
    );
    let _ = writeln!(stderr);

    // Run interactive selection
    let selected = run_selector(&folder_name);

    let level = OPTIONS[selected].0;
    save_trust(working_dir, level);

    // Print result
    let label = match level {
        TrustLevel::Full => "\x1b[32m✓ Trusted\x1b[0m — full tool access enabled",
        TrustLevel::ReadOnly => "\x1b[33m◐ Read-only\x1b[0m — write tools disabled",
        TrustLevel::Untrusted => "\x1b[31m✗ Untrusted\x1b[0m — all tools disabled",
    };
    let _ = writeln!(stderr, "  {label}");
    let _ = writeln!(stderr);

    level
}

/// Arrow-key menu selector. Returns the chosen index (0..OPTIONS.len()).
fn run_selector(folder_name: &str) -> usize {
    let mut selected: usize = 0;
    let mut stderr = std::io::stderr();

    // Enable raw mode for key capture
    let _ = terminal::enable_raw_mode();
    // Hide cursor
    let _ = crossterm::execute!(stderr, crossterm::cursor::Hide);

    render_options(&mut stderr, selected, folder_name);

    loop {
        if let Ok(Event::Key(KeyEvent { code, .. })) = event::read() {
            match code {
                KeyCode::Up | KeyCode::Char('k') => {
                    selected = selected.saturating_sub(1);
                }
                KeyCode::Down | KeyCode::Char('j') => {
                    if selected + 1 < OPTIONS.len() {
                        selected += 1;
                    }
                }
                KeyCode::Enter | KeyCode::Char(' ') => {
                    break;
                }
                KeyCode::Char('q') | KeyCode::Esc => {
                    selected = OPTIONS.len() - 1; // default to untrusted
                    break;
                }
                _ => {}
            }
            // Clear previous render and redraw
            clear_options(&mut stderr);
            render_options(&mut stderr, selected, folder_name);
        }
    }

    // Show cursor, disable raw mode
    let _ = crossterm::execute!(stderr, crossterm::cursor::Show);
    let _ = terminal::disable_raw_mode();

    // Clear the menu lines after selection
    clear_options(&mut stderr);

    selected
}

fn render_options(w: &mut impl Write, selected: usize, folder_name: &str) {
    let colors = ["\x1b[32m", "\x1b[33m", "\x1b[31m"]; // green, yellow, red

    for (i, (_, label, hint)) in OPTIONS.iter().enumerate() {
        let suffix = if i == 0 {
            format!(" ({folder_name})")
        } else {
            String::new()
        };

        if i == selected {
            let _ = write!(
                w,
                "\r  {}{}\x1b[0m\x1b[1m {label}{suffix}\x1b[0m  \x1b[90m{hint}\x1b[0m\r\n",
                colors[i], colors[i],
            );
        } else {
            let _ = write!(w, "\r    \x1b[90m{label}{suffix}\x1b[0m\r\n",);
        }
    }
    let _ = w.flush();
}

fn clear_options(w: &mut impl Write) {
    // Move up OPTIONS.len() lines and clear each
    for _ in 0..OPTIONS.len() {
        let _ = write!(w, "\x1b[A\x1b[2K");
    }
    let _ = w.flush();
}