sillok 0.8.0

A structured CLI chronicle for agentic daily work logs.
Documentation
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;

use assert_cmd::Command as AssertCommand;
use serde_json::Value;

pub fn boxed_error(message: String) -> Box<dyn std::error::Error> {
    Box::new(std::io::Error::other(message))
}

pub fn temp_store() -> Result<(tempfile::TempDir, PathBuf), Box<dyn std::error::Error>> {
    let dir = match tempfile::tempdir() {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    let store = dir.path().join("sillok.db");
    Ok((dir, store))
}

pub fn run_json(store: &Path, args: &[&str]) -> Result<Value, Box<dyn std::error::Error>> {
    let mut json_args = Vec::with_capacity(args.len() + 1);
    json_args.push("--json");
    json_args.extend(args.iter().copied());
    let stdout = run_stdout(store, &json_args)?;
    parse_json(&stdout)
}

pub fn run_failure_json(store: &Path, args: &[&str]) -> Result<Value, Box<dyn std::error::Error>> {
    let mut command = match AssertCommand::cargo_bin("sillok") {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    command.arg("--store").arg(store);
    command.env("TZ", "UTC");
    command.arg("--json");
    for arg in args {
        command.arg(arg);
    }
    let output = match command.output() {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    if output.status.success() {
        return Err(boxed_error("command unexpectedly succeeded".to_string()));
    }
    let stdout = match String::from_utf8(output.stdout) {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    parse_json(&stdout)
}

pub fn init_bare_remote(dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let remote = dir.join("remote.git");
    let remote_arg = remote.display().to_string();
    run_git(None, &["init", "--bare", &remote_arg])?;
    Ok(remote)
}

pub fn clone_remote(remote: &Path, target: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let remote_arg = remote.display().to_string();
    let target_arg = target.display().to_string();
    run_git(
        None,
        &["clone", "--branch", "main", &remote_arg, &target_arg],
    )?;
    Ok(())
}

pub fn remote_set(store: &Path, remote: &Path) -> Result<Value, Box<dyn std::error::Error>> {
    let remote_arg = remote.display().to_string();
    run_json(store, &["sync", "remote", "set", &remote_arg])
}

fn run_stdout(store: &Path, args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
    let mut command = match AssertCommand::cargo_bin("sillok") {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    command.arg("--store").arg(store);
    command.env("TZ", "UTC");
    for arg in args {
        command.arg(arg);
    }
    let output = match command.output() {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        return Err(boxed_error(format!(
            "command failed: stdout={stdout} stderr={stderr}"
        )));
    }
    match String::from_utf8(output.stdout) {
        Ok(value) => Ok(value),
        Err(error) => Err(Box::new(error)),
    }
}

fn parse_json(stdout: &str) -> Result<Value, Box<dyn std::error::Error>> {
    match serde_json::from_str::<Value>(stdout) {
        Ok(value) => Ok(value),
        Err(error) => Err(boxed_error(format!(
            "json parse failed: {error}; stdout={stdout}"
        ))),
    }
}

fn run_git(cwd: Option<&Path>, args: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
    let mut command = ProcessCommand::new("git");
    if let Some(path) = cwd {
        command.current_dir(path);
    }
    for arg in args {
        command.arg(arg);
    }
    let output = match command.output() {
        Ok(value) => value,
        Err(error) => return Err(Box::new(error)),
    };
    if output.status.success() {
        match String::from_utf8(output.stdout) {
            Ok(value) => Ok(value),
            Err(error) => Err(Box::new(error)),
        }
    } else {
        Err(boxed_error(format!(
            "git failed: stdout={} stderr={}",
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr)
        )))
    }
}