tsk-bin 0.20.0

tsk — work with a clear context
use std::ffi::OsString;
use std::path::{Path, PathBuf};

use clap::Subcommand;

pub const CONFIG_DIR_NAME: &str = "tsk";
pub const CONFIG_FILE_NAME: &str = "config.toml";

#[derive(Subcommand)]
pub enum ConfigCommands {
    #[command(
        about = "Record the nexus repo URL in the user config, replacing any URL already recorded"
    )]
    AttachNexus {
        #[arg(help = "Clone URL of the nexus repo")]
        url: String,
    },
    #[command(about = "Print the user config file path and the nexus URL it records")]
    Show,
    #[command(
        name = "ledger-refs",
        about = "Print which refs hold this repo's ledger and where that was decided, or set them in .tsk/config.toml",
        long_about = "Print which refs hold this repo's ledger and where that was decided, or set them in .tsk/config.toml.\n\nbranches: refs/heads/tsk/ledger and refs/heads/tsk/docs. GitHub lists them as branches. A repo used from Claude Code cloud sessions needs them, because the cloud proxy refuses to push a ref outside refs/heads/*.\ncustom: refs/tsk/ledger and refs/tsk/docs. GitHub does not list them. The default for a new ledger outside a cloud session.\n\nThe mode is read from the nexus entry of the repo (ledger_refs), then .tsk/config.toml, then discovered on origin. tsk does not move a ledger that origin holds from one mode to the other."
    )]
    LedgerRefs {
        #[arg(
            value_parser = ["branches", "custom"],
            help = "The mode to write to .tsk/config.toml; omit it to print the mode in force"
        )]
        mode: Option<String>,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttachOutcome {
    Attached,
    Unchanged,
    Replaced { previous: String },
}

pub fn config_file(xdg_config_home: Option<OsString>, home: Option<OsString>) -> Option<PathBuf> {
    let base = match xdg_config_home.filter(|v| !v.is_empty()) {
        Some(xdg) => PathBuf::from(xdg),
        None => PathBuf::from(home.filter(|v| !v.is_empty())?).join(".config"),
    };
    Some(base.join(CONFIG_DIR_NAME).join(CONFIG_FILE_NAME))
}

pub fn config_file_from_env() -> Option<PathBuf> {
    config_file(
        std::env::var_os("XDG_CONFIG_HOME"),
        std::env::var_os("HOME"),
    )
}

fn read_table(file: &Path) -> Result<toml::Table, String> {
    match std::fs::read_to_string(file) {
        Ok(text) => text.parse::<toml::Table>().map_err(|e| {
            format!(
                "error: {} is not valid TOML: {}",
                file.display(),
                e.message()
            )
        }),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(toml::Table::new()),
        Err(e) => Err(format!("error: could not read {}: {}", file.display(), e)),
    }
}

pub fn read_nexus_url(file: &Path) -> Result<Option<String>, String> {
    let table = read_table(file)?;
    match table.get("nexus") {
        None => Ok(None),
        Some(toml::Value::Table(nexus)) => match nexus.get("url") {
            None => Ok(None),
            Some(toml::Value::String(url)) if !url.trim().is_empty() => {
                Ok(Some(url.trim().to_string()))
            }
            Some(_) => Err(format!(
                "error: nexus.url in {} must be a non-empty string",
                file.display()
            )),
        },
        Some(_) => Err(format!(
            "error: nexus in {} must be a table with a url key",
            file.display()
        )),
    }
}

/// Environment variable that once chose the ledger backend. A ledger is a Dolt database
/// now. The variable is read only to refuse the value `git`.
pub const BACKEND_ENV: &str = "TSK_LEDGER_BACKEND";

/// What a user who still selects the removed files backend is told.
pub const FILES_BACKEND_REMOVED: &str = "error: the files ledger backend was removed in 0.16.0; use tsk 0.15.1 to read an old file ledger";

/// The `[ledger] backend` value in the config file. `None` when the file, the table, the
/// key or its string value is absent.
fn read_ledger_backend(file: &Path) -> Option<String> {
    let table = read_table(file).ok()?;
    match table.get("ledger")?.as_table()?.get("backend")? {
        toml::Value::String(value) => Some(value.clone()),
        _ => None,
    }
}

/// Refuse the removed files backend. The environment value wins over the config file,
/// and an empty value counts as unset. Any value other than `git` is ignored.
pub fn refuse_files_backend(env: Option<String>, file: Option<&Path>) -> Result<(), String> {
    let chosen = env
        .filter(|v| !v.trim().is_empty())
        .or_else(|| file.and_then(read_ledger_backend));
    match chosen {
        Some(value) if value.trim() == "git" => Err(FILES_BACKEND_REMOVED.to_string()),
        _ => Ok(()),
    }
}

pub fn refuse_files_backend_from_env() -> Result<(), String> {
    refuse_files_backend(
        std::env::var(BACKEND_ENV).ok(),
        config_file_from_env().as_deref(),
    )
}

pub fn attach_nexus(file: &Path, url: &str) -> Result<AttachOutcome, String> {
    let url = url.trim();
    if url.is_empty() {
        return Err("error: the nexus URL is empty".to_string());
    }
    let previous = read_nexus_url(file)?;
    if previous.as_deref() == Some(url) {
        return Ok(AttachOutcome::Unchanged);
    }
    let mut table = read_table(file)?;
    let mut nexus = toml::Table::new();
    nexus.insert("url".to_string(), toml::Value::String(url.to_string()));
    table.insert("nexus".to_string(), toml::Value::Table(nexus));
    let text = toml::to_string(&table)
        .map_err(|e| format!("error: could not serialise the config: {}", e))?;
    if let Some(parent) = file.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|e| format!("error: could not create {}: {}", parent.display(), e))?;
    }
    std::fs::write(file, text)
        .map_err(|e| format!("error: could not write {}: {}", file.display(), e))?;
    Ok(match previous {
        Some(previous) => AttachOutcome::Replaced { previous },
        None => AttachOutcome::Attached,
    })
}

pub fn required_config_file() -> Result<PathBuf, String> {
    config_file_from_env().ok_or_else(|| {
        "error: neither XDG_CONFIG_HOME nor HOME is set; cannot locate the user config".to_string()
    })
}

pub fn attach_and_report(file: &Path, url: &str) -> Result<(), String> {
    let url = url.trim();
    match attach_nexus(file, url)? {
        AttachOutcome::Attached => println!("attached nexus {}", url),
        AttachOutcome::Unchanged => println!("nexus already attached: {}", url),
        AttachOutcome::Replaced { previous } => {
            println!("replaced nexus {} with {}", previous, url)
        }
    }
    Ok(())
}

pub fn run(action: ConfigCommands) -> Result<(), String> {
    if let ConfigCommands::LedgerRefs { mode } = action {
        return match mode
            .as_deref()
            .and_then(crate::ledger::refs::LedgerRefs::parse)
        {
            Some(mode) => crate::ledger::refs_cmd::set(mode),
            None => crate::ledger::refs_cmd::show(),
        };
    }
    let file = required_config_file()?;
    match action {
        ConfigCommands::LedgerRefs { .. } => unreachable!("handled above"),
        ConfigCommands::AttachNexus { url } => attach_and_report(&file, &url),
        ConfigCommands::Show => {
            println!("config: {}", file.display());
            match read_nexus_url(&file)? {
                Some(url) => println!("nexus: {}", url),
                None => println!("nexus: none attached"),
            }
            Ok(())
        }
    }
}

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

    #[test]
    fn config_file_prefers_xdg_config_home() {
        assert_eq!(
            config_file(Some("/x/cfg".into()), Some("/home/u".into())),
            Some(PathBuf::from("/x/cfg/tsk/config.toml"))
        );
    }

    #[test]
    fn config_file_treats_empty_xdg_as_unset() {
        assert_eq!(
            config_file(Some("".into()), Some("/home/u".into())),
            Some(PathBuf::from("/home/u/.config/tsk/config.toml"))
        );
    }

    #[test]
    fn config_file_is_none_without_xdg_or_home() {
        assert_eq!(config_file(None, None), None);
    }

    #[test]
    fn read_nexus_url_is_none_for_a_missing_file_or_key() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("tsk").join("config.toml");
        assert_eq!(read_nexus_url(&file).unwrap(), None);
        std::fs::create_dir_all(file.parent().unwrap()).unwrap();
        std::fs::write(&file, "other = 1\n").unwrap();
        assert_eq!(read_nexus_url(&file).unwrap(), None);
    }

    #[test]
    fn the_files_backend_is_refused_from_the_environment_or_the_file() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        assert!(refuse_files_backend(None, Some(&file)).is_ok());
        assert_eq!(
            refuse_files_backend(Some("git".into()), None).unwrap_err(),
            FILES_BACKEND_REMOVED
        );
        std::fs::write(&file, "[ledger]\nbackend = \"git\"\n").unwrap();
        assert_eq!(
            refuse_files_backend(None, Some(&file)).unwrap_err(),
            FILES_BACKEND_REMOVED
        );
        // The environment wins over the file, and an empty value is unset.
        assert!(refuse_files_backend(Some("dolt".into()), Some(&file)).is_ok());
        assert!(refuse_files_backend(Some("".into()), Some(&file)).is_err());
    }

    #[test]
    fn any_other_backend_value_is_ignored() {
        assert!(refuse_files_backend(Some("sqlite".into()), None).is_ok());
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        std::fs::write(&file, "[ledger]\nbackend = \"mongo\"\n").unwrap();
        assert!(refuse_files_backend(None, Some(&file)).is_ok());
        std::fs::write(&file, "[ledger]\nbackend = 3\n").unwrap();
        assert!(refuse_files_backend(None, Some(&file)).is_ok());
    }

    #[test]
    fn attaching_a_nexus_keeps_the_ledger_table() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        std::fs::write(&file, "[ledger]\nbackend = \"dolt\"\n").unwrap();
        attach_nexus(&file, "https://a.test/n").unwrap();
        assert_eq!(read_ledger_backend(&file).as_deref(), Some("dolt"));
    }

    #[test]
    fn attach_creates_the_file_and_reads_back() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("tsk").join("config.toml");
        assert_eq!(
            attach_nexus(&file, "https://example.test/o/nexus").unwrap(),
            AttachOutcome::Attached
        );
        assert_eq!(
            read_nexus_url(&file).unwrap().as_deref(),
            Some("https://example.test/o/nexus")
        );
    }

    #[test]
    fn attach_is_idempotent_and_reports_a_replacement() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        attach_nexus(&file, "https://a.test/n").unwrap();
        assert_eq!(
            attach_nexus(&file, "https://a.test/n").unwrap(),
            AttachOutcome::Unchanged
        );
        assert_eq!(
            attach_nexus(&file, "https://b.test/n").unwrap(),
            AttachOutcome::Replaced {
                previous: "https://a.test/n".to_string()
            }
        );
        assert_eq!(
            read_nexus_url(&file).unwrap().as_deref(),
            Some("https://b.test/n")
        );
    }

    #[test]
    fn attach_keeps_other_keys() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        std::fs::write(&file, "colour = \"blue\"\n").unwrap();
        attach_nexus(&file, "https://a.test/n").unwrap();
        let text = std::fs::read_to_string(&file).unwrap();
        assert!(text.contains("colour = \"blue\""), "{}", text);
    }

    #[test]
    fn attach_rejects_an_empty_url() {
        let dir = tempfile::tempdir().unwrap();
        assert!(attach_nexus(&dir.path().join("c.toml"), "  ").is_err());
    }

    #[test]
    fn read_nexus_url_rejects_malformed_toml_and_wrong_types() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("config.toml");
        std::fs::write(&file, "nexus = \n").unwrap();
        assert!(read_nexus_url(&file).is_err());
        std::fs::write(&file, "nexus = \"x\"\n").unwrap();
        assert!(read_nexus_url(&file).is_err());
        std::fs::write(&file, "[nexus]\nurl = 3\n").unwrap();
        assert!(read_nexus_url(&file).is_err());
    }
}