inkhaven 1.4.10

Inkhaven — TUI literary work editor for Typst books
//! `inkhaven inner-editor …` — the terminal surface for the Inner Editor
//! literary/stylistic companion. `engage` runs one Editor pass over a paragraph
//! (the same engine the TUI chord/ambient trigger uses); `findings` inspects
//! what's persisted; `config show` and `usage` report state. Read-only except
//! the engagement, which records findings + usage like the TUI path.

use std::path::Path;

use uuid::Uuid;

use crate::config::Config;
use crate::error::{Error, Result};
use crate::inner_editor::types::EditorSeverity;
use crate::inner_editor::{self, EngageInput, InnerEditorStore};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::Store;

use super::{
    EditorConfigCommand, EditorFindingsCommand, EditorSuggestionsCommand, InnerEditorCommand,
};

fn parse_category(category: &str) -> Result<crate::inner_editor::types::EditorCategory> {
    use crate::inner_editor::types::EditorCategory;
    EditorCategory::from_id(category).ok_or_else(|| {
        let all: Vec<&str> = EditorCategory::ALL.iter().map(|c| c.id()).collect();
        Error::Config(format!("unknown category `{category}`. One of: {}", all.join(", ")))
    })
}

fn suggestions_list(project: &Path, threshold: i64) -> Result<()> {
    let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    let cands = store
        .promotion_candidates(threshold)
        .map_err(|e| Error::Store(format!("{e}")))?;
    if cands.is_empty() {
        println!("no promotion candidates (threshold {threshold})");
        return Ok(());
    }
    for c in &cands {
        let scope = if c.chapter_id.is_empty() {
            "project-wide".to_string()
        } else {
            format!("chapter {}", c.chapter_id)
        };
        println!("✎ [{}] dismissed {}× ({scope})", c.category.label(), c.count);
    }
    println!(
        "\n{} candidate(s) — `inner-editor suggestions promote <category>` to declare deliberate",
        cands.len()
    );
    Ok(())
}

fn suggestions_promote(
    project: &Path,
    category: String,
    chapter: Option<String>,
    description: Option<String>,
) -> Result<()> {
    let cat = parse_category(&category)?;
    crate::inner_editor::intent_declare::declare_intent(
        project,
        cat,
        chapter.as_deref(),
        description.as_deref(),
    )
    .map_err(|e| Error::Store(format!("{e}")))?;
    // Stop re-suggesting this pattern.
    let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    let _ = store.refuse_promotion(cat, chapter.as_deref().unwrap_or(""));
    let scope = chapter.as_deref().map(|c| format!("chapter {c}")).unwrap_or_else(|| "project-wide".into());
    println!("✓ promoted [{}] to a declared intent ({scope}) — future findings suppressed", cat.label());
    Ok(())
}

fn suggestions_dismiss(project: &Path, category: String, chapter: Option<String>) -> Result<()> {
    let cat = parse_category(&category)?;
    let store = crate::inner_editor::InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    store
        .refuse_promotion(cat, chapter.as_deref().unwrap_or(""))
        .map_err(|e| Error::Store(format!("{e}")))?;
    println!("✓ won't suggest promoting [{}] again", cat.label());
    Ok(())
}

pub fn run(project: &Path, cmd: InnerEditorCommand) -> Result<()> {
    match cmd {
        InnerEditorCommand::Engage { text, paragraph, force } => {
            engage(project, text, paragraph, force)
        }
        InnerEditorCommand::Findings(c) => match c {
            EditorFindingsCommand::List { severity } => findings_list(project, severity),
            EditorFindingsCommand::History { paragraph } => findings_history(project, paragraph),
        },
        InnerEditorCommand::Intent { category, chapter, description } => {
            declare_intent(project, category, chapter, description)
        }
        InnerEditorCommand::Suggestions(c) => match c {
            EditorSuggestionsCommand::List { threshold } => suggestions_list(project, threshold),
            EditorSuggestionsCommand::Promote { category, chapter, description } => {
                suggestions_promote(project, category, chapter, description)
            }
            EditorSuggestionsCommand::Dismiss { category, chapter } => {
                suggestions_dismiss(project, category, chapter)
            }
        },
        InnerEditorCommand::Config(c) => match c {
            EditorConfigCommand::Show => config_show(project),
        },
        InnerEditorCommand::Usage => usage(project),
    }
}

/// Run one engagement over a literal `--text` or a `--paragraph <id>`.
fn engage(
    project: &Path,
    text: Option<String>,
    paragraph: Option<String>,
    force: bool,
) -> Result<()> {
    let layout = ProjectLayout::new(project);
    layout.require_initialized()?;
    let cfg = Config::load_layered(&layout.config_path())?;

    let (prose, preceding, paragraph_id, chapter_id) = match (text, paragraph) {
        (Some(t), _) => (t, Vec::new(), None, None),
        (None, Some(pid_s)) => {
            let pid = Uuid::parse_str(&pid_s)
                .map_err(|e| Error::Config(format!("bad paragraph id `{pid_s}`: {e}")))?;
            let store = Store::open(layout.clone(), &cfg)?;
            let h = Hierarchy::load(&store)?;
            let gc = inner_editor::gather_context(
                &store,
                &h,
                pid,
                cfg.inner_editor.context.preceding_paragraphs,
            )
            .ok_or_else(|| Error::Config(format!("paragraph `{pid}` not found")))?;
            (gc.prose, gc.preceding, Some(pid), gc.chapter_id)
        }
        (None, None) => {
            return Err(Error::Config("give --text \"\" or --paragraph <id>".into()))
        }
    };

    let outcome = inner_editor::engage(EngageInput {
        project: project.to_path_buf(),
        paragraph_id,
        chapter_id,
        prose,
        preceding,
        language: String::new(),
        snapshot_id: None,
        system_override: None,
        force,
    })
    .map_err(|e| Error::Store(format!("{e}")))?;

    if let Some(note) = &outcome.note {
        eprintln!("{note}");
    }
    if outcome.findings.is_empty() {
        println!("\u{270e} no observations");
        return Ok(());
    }
    for f in &outcome.findings {
        println!("\u{270e} {} [{}] {}", f.severity.label(), f.category.label(), f.observation);
        if let Some(ev) = &f.evidence {
            println!("    evidence: {ev}");
        }
    }
    let supp = if outcome.suppressed > 0 {
        format!(" · {} suppressed by declared intent", outcome.suppressed)
    } else {
        String::new()
    };
    println!("\n{} observation(s){supp}", outcome.findings.len());
    Ok(())
}

fn declare_intent(
    project: &Path,
    category: String,
    chapter: Option<String>,
    description: Option<String>,
) -> Result<()> {
    use crate::inner_editor::types::EditorCategory;
    let cat = EditorCategory::from_id(&category).ok_or_else(|| {
        let all: Vec<&str> = EditorCategory::ALL.iter().map(|c| c.id()).collect();
        Error::Config(format!("unknown category `{category}`. One of: {}", all.join(", ")))
    })?;
    crate::inner_editor::intent_declare::declare_intent(
        project,
        cat,
        chapter.as_deref(),
        description.as_deref(),
    )
    .map_err(|e| Error::Store(format!("{e}")))?;
    let scope = chapter.as_deref().map(|c| format!("chapter {c}")).unwrap_or_else(|| "project-wide".into());
    println!("✓ declared [{}] a deliberate choice ({scope}) — future findings suppressed", cat.label());
    Ok(())
}

fn findings_list(project: &Path, severity: Option<String>) -> Result<()> {
    let store = InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    let filter = severity.map(|s| EditorSeverity::from_id(&s));
    let all = store.list_findings().map_err(|e| Error::Store(format!("{e}")))?;
    let shown: Vec<_> = all
        .iter()
        .filter(|sf| filter.is_none_or(|fl| sf.finding.severity == fl))
        .collect();
    if shown.is_empty() {
        println!("no findings");
        return Ok(());
    }
    for sf in &shown {
        println!(
            "\u{270e} {} [{}] {}",
            sf.finding.severity.label(),
            sf.finding.category.label(),
            sf.finding.observation
        );
    }
    println!("\n{} finding(s)", shown.len());
    Ok(())
}

fn findings_history(project: &Path, paragraph: String) -> Result<()> {
    let pid = Uuid::parse_str(&paragraph)
        .map_err(|e| Error::Config(format!("bad paragraph id `{paragraph}`: {e}")))?;
    let store = InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    let hist = store.findings_history(pid).map_err(|e| Error::Store(format!("{e}")))?;
    if hist.is_empty() {
        println!("no history for paragraph {pid}");
        return Ok(());
    }
    for (at, f) in &hist {
        let when = chrono::DateTime::<chrono::Utc>::from_timestamp(*at, 0)
            .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
            .unwrap_or_default();
        println!(
            "{when}  \u{270e} {} [{}] {}",
            f.severity.label(),
            f.category.label(),
            f.observation
        );
    }
    println!("\n{} finding(s) over time.", hist.len());
    Ok(())
}

fn config_show(project: &Path) -> Result<()> {
    let cfg = Config::load_layered(&ProjectLayout::new(project).config_path())?;
    let ie = &cfg.inner_editor;
    println!("Inner Editor — {}", if ie.enabled { "enabled" } else { "disabled" });
    println!(
        "  tone: {} · verbosity: {} · praise: {}",
        ie.persona.tone, ie.persona.verbosity, ie.persona.praise_frequency
    );
    println!(
        "  genre-aware: {} (genre: {}) · belief stance: {}",
        ie.persona.genre_aware,
        cfg.genre.as_deref().unwrap_or("none declared"),
        ie.persona.belief_stance_enabled
    );
    println!(
        "  idle: {}s · cooldown: {}s · max findings/¶: {}",
        ie.engagement.idle_threshold_seconds,
        ie.engagement.cooldown_seconds,
        ie.engagement.max_findings_per_paragraph
    );
    println!("  context: {} preceding ¶", ie.context.preceding_paragraphs);
    let thresh_note = if ie.output.severity_threshold == "note" {
        " (Praise hidden by default)"
    } else {
        ""
    };
    println!("  output threshold: {}{thresh_note}", ie.output.severity_threshold);
    let tuning = inner_editor::resolve_tuning(&ie.persona);
    let cats: Vec<&str> = tuning.active_categories.iter().map(|c| c.id()).collect();
    println!("  active categories ({}): {}", cats.len(), cats.join(", "));
    Ok(())
}

fn usage(project: &Path) -> Result<()> {
    let cfg = Config::load_layered(&ProjectLayout::new(project).config_path())?;
    crate::dayclock::set_boundary(cfg.goals.day_boundary);
    let day = crate::dayclock::today_key();
    let store = InnerEditorStore::open_for_project(project)
        .map_err(|e| Error::Store(format!("inner-editor store: {e}")))?;
    let rows = store.llm_usage_today(&day).map_err(|e| Error::Store(format!("{e}")))?;
    println!("Inner Editor usage — {day}");
    if rows.is_empty() {
        println!("  no calls today");
    }
    for (sub, calls) in &rows {
        println!("  {sub}: {calls} call(s)");
    }
    let eng = store
        .llm_calls_today(&day, InnerEditorStore::ENGAGEMENT_SUB_BUDGET)
        .unwrap_or(0);
    let cap = cfg.inner_editor.llm.editor_engagement.max_calls_per_day;
    println!("  daily cap (informative): {eng}/{cap}");
    Ok(())
}