tsk-bin 0.2.0

tsk — work with a clear context
use std::io::{Read, Write};
use std::path::Path;

use serde::Serialize;

use crate::ledger::fetch::{fetch, FetchOutcome};

use super::binding::{resolve_at, UNBOUND_PROMPT};
use super::session::Session;

pub const ENV_FILE_VAR: &str = "CLAUDE_ENV_FILE";
pub const LEDGER_WT_VAR: &str = "TSK_LEDGER_WT";

#[derive(Serialize)]
struct SpecificOutput<'a> {
    #[serde(rename = "hookEventName")]
    hook_event_name: &'a str,
    #[serde(rename = "additionalContext")]
    additional_context: &'a str,
}

#[derive(Serialize)]
struct HookOutput<'a> {
    #[serde(rename = "hookSpecificOutput")]
    hook_specific_output: SpecificOutput<'a>,
}

pub fn hook_output(context: &str) -> String {
    serde_json::to_string(&HookOutput {
        hook_specific_output: SpecificOutput {
            hook_event_name: "SessionStart",
            additional_context: context,
        },
    })
    .unwrap_or_default()
}

pub fn fetched_context(path: &Path, pending: &PendingNote, thread_message: &str) -> String {
    let wt = path.display();
    let mut context = format!(
        "The ledger was fetched and materialised at {} (also exported as ${}). Read {}/index.md next.",
        wt, LEDGER_WT_VAR, wt
    );
    if !pending.lines.is_empty() {
        context.push(' ');
        context.push_str(&pending_warning(path, pending));
    }
    context.push(' ');
    context.push_str(thread_message);
    context
}

pub struct PendingNote {
    pub remote_ref: String,
    pub origin_commit: String,
    pub lines: Vec<String>,
}

impl PendingNote {
    pub fn from_outcome(outcome: &FetchOutcome) -> PendingNote {
        PendingNote {
            remote_ref: format!(
                "{}'s {}",
                outcome.location.label(),
                outcome.location.ref_name()
            ),
            origin_commit: outcome.commit.clone(),
            lines: outcome.pending.clone(),
        }
    }
}

fn pending_warning(path: &Path, pending: &PendingNote) -> String {
    format!(
        "WARNING: the ledger worktree holds a commit that is not on {}, so it was left as it is rather than reset: {}. A worker restart can end a turn between a commit and its push, which leaves exactly this state, so this may be your own work from a turn you hold no record of making. Do not assume another actor made it, and do not discard it on that basis. Read what it changes first, with: git -C '{}' log -p {}..HEAD. Then push it with tsk ledger push, or discard it deliberately once you know what it is.",
        pending.remote_ref,
        pending.lines.join(";"),
        path.display(),
        pending.origin_commit
    )
}

pub fn thread_message(binding: Option<&str>) -> String {
    match binding {
        Some(binding) => {
            let thread_id = binding.split_once(':').map(|(_, id)| id).unwrap_or(binding);
            format!(
                "An existing thread binding was found: {}. Run /tsk:resume-thread {} next.",
                binding, thread_id
            )
        }
        None => UNBOUND_PROMPT.to_string(),
    }
}

pub fn failure_context(error: &str) -> String {
    format!(
        "Warning: the ledger could not be fetched automatically at session start ({}). Run `tsk ledger fetch` manually before reading index.md.",
        error.replace('\n', " ").trim()
    )
}

fn export_to_env_file(path: &Path) -> Result<(), String> {
    let Some(file) = std::env::var_os(ENV_FILE_VAR).filter(|v| !v.is_empty()) else {
        return Ok(());
    };
    let mut handle = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&file)
        .map_err(|e| format!("could not open {}: {}", Path::new(&file).display(), e))?;
    writeln!(handle, "export {}=\"{}\"", LEDGER_WT_VAR, path.display())
        .map_err(|e| format!("could not write {}: {}", Path::new(&file).display(), e))
}

fn context_after_fetch(session: &Session) -> Result<String, String> {
    let outcome = fetch(&session.repo, &session.state_root)?;
    let binding = resolve_at(session, &outcome.path)
        .ok()
        .flatten()
        .map(|b| b.to_string());
    let mut context = fetched_context(
        &outcome.path,
        &PendingNote::from_outcome(&outcome),
        &thread_message(binding.as_deref()),
    );
    if let Err(e) = export_to_env_file(&outcome.path) {
        context.push_str(&format!(
            " WARNING: {} was not exported ({}); use the path above.",
            LEDGER_WT_VAR, e
        ));
    }
    Ok(context)
}

pub fn run() -> i32 {
    let _ = std::io::stdin().read_to_end(&mut Vec::new());
    let context = Session::discover()
        .and_then(|session| context_after_fetch(&session))
        .unwrap_or_else(|e| failure_context(&e));
    println!("{}", hook_output(&context));
    0
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn no_pending() -> PendingNote {
        PendingNote {
            remote_ref: "origin's refs/heads/tsk/ledger".to_string(),
            origin_commit: "abc123".to_string(),
            lines: Vec::new(),
        }
    }

    #[test]
    fn hook_output_is_one_compact_object_in_hook_order() {
        let text = hook_output("a \"b\"");
        assert_eq!(
            text,
            "{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"a \\\"b\\\"\"}}"
        );
    }

    #[test]
    fn fetched_context_names_the_path_and_the_next_read() {
        let path = PathBuf::from("/s/ledger");
        let context = fetched_context(&path, &no_pending(), "Next.");
        assert_eq!(
            context,
            "The ledger was fetched and materialised at /s/ledger (also exported as $TSK_LEDGER_WT). Read /s/ledger/index.md next. Next."
        );
    }

    #[test]
    fn fetched_context_adds_the_pending_warning() {
        let path = PathBuf::from("/s/ledger");
        let pending = PendingNote {
            lines: vec!["1111111 first".to_string(), "2222222 second".to_string()],
            ..no_pending()
        };
        let context = fetched_context(&path, &pending, "Next.");
        assert!(context.contains(
            "WARNING: the ledger worktree holds a commit that is not on origin's refs/heads/tsk/ledger"
        ));
        assert!(context.contains("1111111 first;2222222 second"));
        assert!(context.contains("git -C '/s/ledger' log -p abc123..HEAD"));
        assert!(context.contains("push it with tsk ledger push"));
        assert!(context.ends_with(" Next."));
    }

    #[test]
    fn thread_message_names_the_binding_and_the_resume_command() {
        assert_eq!(
            thread_message(Some("worktree:1234abcd")),
            "An existing thread binding was found: worktree:1234abcd. Run /tsk:resume-thread 1234abcd next."
        );
        assert_eq!(thread_message(None), UNBOUND_PROMPT);
    }

    #[test]
    fn failure_context_asks_for_a_manual_fetch() {
        let context = failure_context("error: git fetch failed\nfatal: no remote");
        assert!(context.contains("error: git fetch failed fatal: no remote"));
        assert!(context.contains("Run `tsk ledger fetch` manually"));
    }
}