niuse 0.1.14

A personal engineering companion built on niucode
//! `niuse mcp init`: write the bundled read-only packs and a trust manifest.

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use niuse_core::Store;

const NOTES_SERVER: &str = include_str!("../../assets/packs/notes/server.py");
const CALENDAR_SERVER: &str = include_str!("../../assets/packs/calendar/server.py");

pub struct InitOptions {
    pub notes: Option<PathBuf>,
    pub calendar: Option<PathBuf>,
    pub config: Option<PathBuf>,
    pub packs: Option<PathBuf>,
    pub force: bool,
}

pub fn init(store: &Store, options: InitOptions) -> Result<()> {
    if options.notes.is_none() && options.calendar.is_none() {
        bail!("mcp init needs --notes <dir> and/or --calendar <file.ics>");
    }
    let config = match options.config {
        Some(path) => path,
        None => crate::projects::default_root()?.join("mcp.toml"),
    };
    let packs = options.packs.unwrap_or_else(|| store.root().join("packs"));
    let python = python()?;

    let mut servers: Vec<(String, String)> = Vec::new();
    if let Some(notes) = options.notes {
        if !notes.is_dir() {
            bail!("--notes is not a directory: {}", notes.display());
        }
        let script = packs.join("notes/server.py");
        write_script(&script, NOTES_SERVER)?;
        servers.push((
            "notes".to_string(),
            format!(
                "[[server]]\nname = \"notes\"\ncommand = {}\nargs = [{}]\nread_only = true\n\n[server.env]\nNOTES_DIR = {}\n",
                toml_string(&python),
                toml_string(&script.display().to_string()),
                toml_string(&notes.display().to_string())
            ),
        ));
    }
    if let Some(calendar) = options.calendar {
        if !calendar.is_file() {
            bail!("--calendar is not a file: {}", calendar.display());
        }
        let script = packs.join("calendar/server.py");
        write_script(&script, CALENDAR_SERVER)?;
        servers.push((
            "calendar".to_string(),
            format!(
                "[[server]]\nname = \"calendar\"\ncommand = {}\nargs = [{}]\nread_only = true\n\n[server.env]\nCALENDAR_ICS = {}\n",
                toml_string(&python),
                toml_string(&script.display().to_string()),
                toml_string(&calendar.display().to_string())
            ),
        ));
    }

    let header = "# Generated by `niuse mcp init`. Read-only servers only.\n# Review before editing; niucode never auto-discovers this file.\n";
    let existing = if config.exists() {
        fs::read_to_string(&config).with_context(|| format!("read {}", config.display()))?
    } else {
        String::new()
    };
    let mut names: HashSet<String> = HashSet::new();
    if config.exists() && !options.force {
        let parsed = niucode_mcp::McpConfig::load(&config).with_context(|| {
            format!(
                "{} is not a valid manifest; fix it or pass --force",
                config.display()
            )
        })?;
        names = parsed
            .server
            .into_iter()
            .map(|server| server.name)
            .collect();
    }

    let mut added = Vec::new();
    let mut kept = Vec::new();
    let mut blocks = Vec::new();
    for (name, block) in servers {
        if !options.force && names.contains(&name) {
            kept.push(name);
        } else {
            added.push(name);
            blocks.push(block);
        }
    }

    if let Some(parent) = config.parent() {
        create_private_dir(parent)?;
    }
    if options.force || !config.exists() {
        let body = if blocks.is_empty() {
            header.to_string()
        } else {
            format!("{header}\n{}\n", blocks.join("\n"))
        };
        write_private(&config, &body)?;
    } else if !blocks.is_empty() {
        let mut body = existing;
        if !body.ends_with('\n') {
            body.push('\n');
        }
        body.push('\n');
        body.push_str(&blocks.join("\n"));
        body.push('\n');
        write_private(&config, &body)?;
    }

    println!("mcp config {}", config.display());
    println!("packs      {}", packs.display());
    if !added.is_empty() {
        println!("added      {}", added.join(", "));
    }
    if !kept.is_empty() {
        println!("kept       {}", kept.join(", "));
    }
    if added.is_empty() && kept.is_empty() {
        println!("unchanged  no servers requested");
    } else if added.is_empty() {
        println!("unchanged  all requested servers already exist");
    }
    println!("next       niuse tui   (or: niuse exec \"list my notes\")");
    Ok(())
}

fn python() -> Result<String> {
    if let Some(path) = std::env::var_os("PYTHON") {
        let path = PathBuf::from(path);
        if path.is_file() {
            return Ok(path.display().to_string());
        }
    }
    for candidate in [
        "/usr/bin/python3",
        "/usr/local/bin/python3",
        "/opt/homebrew/bin/python3",
    ] {
        if Path::new(candidate).is_file() {
            return Ok(candidate.to_string());
        }
    }
    if let Some(paths) = std::env::var_os("PATH") {
        for directory in std::env::split_paths(&paths) {
            let candidate = directory.join("python3");
            if candidate.is_file() {
                return Ok(candidate.display().to_string());
            }
        }
    }
    bail!("python3 not found; install it or set PYTHON to an absolute interpreter path")
}

fn toml_string(value: &str) -> String {
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

fn write_script(path: &Path, body: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        create_private_dir(parent)?;
    }
    write_private(path, body)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o755))
            .with_context(|| format!("chmod {}", path.display()))?;
    }
    Ok(())
}

fn create_private_dir(path: &Path) -> Result<()> {
    fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
            .with_context(|| format!("chmod {}", path.display()))?;
    }
    Ok(())
}

fn write_private(path: &Path, body: &str) -> Result<()> {
    fs::write(path, body).with_context(|| format!("write {}", path.display()))?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
            .with_context(|| format!("chmod {}", path.display()))?;
    }
    Ok(())
}

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

    #[test]
    fn toml_strings_escape_quotes_and_backslashes() {
        assert_eq!(toml_string(r#"/tmp/a"b\c"#), r#""/tmp/a\"b\\c""#);
    }

    #[test]
    fn init_requires_a_source() {
        let directory = tempfile::tempdir().unwrap();
        let store = Store::at(directory.path().join(".niuse"));
        store.init().unwrap();
        let error = init(
            &store,
            InitOptions {
                notes: None,
                calendar: None,
                config: Some(directory.path().join("mcp.toml")),
                packs: None,
                force: false,
            },
        )
        .unwrap_err();
        assert!(error.to_string().contains("--notes"));
    }
}