sillok 0.9.0

A structured CLI chronicle for agentic daily work logs.
Documentation
use serde::Serialize;
use serde_json::{Value, json};

use crate::domain::time::Timestamp;
use crate::error::{ErrorPayload, SillokError};

/// CLI output rendering mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
    /// Quiet machine output optimized for agent token usage.
    Compact,
    /// Full JSON response envelope.
    Json,
    /// Human-readable command summaries.
    Human,
}

impl OutputMode {
    /// Resolves the rendering mode from the global `--human` / `--json` flags.
    pub fn from_flags(human: bool, json: bool) -> Self {
        if human {
            Self::Human
        } else if json {
            Self::Json
        } else {
            Self::Compact
        }
    }
}

/// Successful command response.
#[derive(Debug, Serialize)]
pub struct SuccessResponse {
    pub ok: bool,
    pub command: &'static str,
    pub generated_at: Timestamp,
    pub ids: Value,
    pub data: Value,
    pub warnings: Vec<String>,
}

/// Failed command response.
#[derive(Debug, Serialize)]
pub struct FailureResponse {
    pub ok: bool,
    pub command: &'static str,
    pub generated_at: Timestamp,
    pub error: ErrorPayload,
}

/// Internal command outcome before printing.
#[derive(Debug)]
pub struct CommandOutcome {
    pub command: &'static str,
    pub ids: Value,
    pub data: Value,
    pub warnings: Vec<String>,
    pub human: Option<String>,
}

impl CommandOutcome {
    /// Builds an outcome with empty ids and warnings.
    pub fn new(command: &'static str, data: Value) -> Self {
        Self {
            command,
            ids: json!({}),
            data,
            warnings: Vec::new(),
            human: None,
        }
    }

    /// Adds ids to the outcome.
    pub fn with_ids(mut self, ids: Value) -> Self {
        self.ids = ids;
        self
    }

    /// Adds warnings to the outcome.
    pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
        self.warnings = warnings;
        self
    }

    /// Adds human-readable output.
    pub fn with_human(mut self, human: String) -> Self {
        self.human = Some(human);
        self
    }

    /// Converts into the public success response.
    pub fn success_response(self, generated_at: Timestamp) -> SuccessResponse {
        SuccessResponse {
            ok: true,
            command: self.command,
            generated_at,
            ids: self.ids,
            data: self.data,
            warnings: self.warnings,
        }
    }
}

/// Prints a command result.
pub fn print_success(outcome: CommandOutcome, mode: OutputMode) -> Result<(), SillokError> {
    match mode {
        OutputMode::Human => match outcome.human {
            Some(value) => {
                println!("{value}");
                Ok(())
            }
            None => {
                println!("{}", serde_json::to_string_pretty(&outcome.data)?);
                Ok(())
            }
        },
        OutputMode::Json => {
            let response = outcome.success_response(Timestamp::now());
            println!("{}", serde_json::to_string(&response)?);
            Ok(())
        }
        OutputMode::Compact => {
            if let Some(response) = compact_success_value(outcome) {
                println!("{}", serde_json::to_string(&response)?);
            }
            Ok(())
        }
    }
}

/// Prints a failure response.
pub fn print_failure(
    command: &'static str,
    error: &SillokError,
    mode: OutputMode,
) -> Result<(), SillokError> {
    match mode {
        OutputMode::Human => {
            println!("{error}");
            Ok(())
        }
        OutputMode::Json => {
            let response = FailureResponse {
                ok: false,
                command,
                generated_at: Timestamp::now(),
                error: error.payload(),
            };
            println!("{}", serde_json::to_string(&response)?);
            Ok(())
        }
        OutputMode::Compact => {
            let payload = error.payload();
            let response = json!({
                "error": payload.code,
                "message": payload.message,
            });
            println!("{}", serde_json::to_string(&response)?);
            Ok(())
        }
    }
}

fn compact_success_value(outcome: CommandOutcome) -> Option<Value> {
    let CommandOutcome {
        command,
        data,
        warnings,
        human: _,
        ids: _,
    } = outcome;
    if should_suppress_success(command, &data) {
        return warning_value(warnings);
    }
    let mut value = data;
    append_warnings(&mut value, warnings);
    Some(value)
}

fn should_suppress_success(command: &'static str, data: &Value) -> bool {
    match command {
        "init" | "note" | "objective" | "amend" | "retract" | "truncate" => true,
        "migrate" => match data.get("dry_run").and_then(Value::as_bool) {
            Some(true) => false,
            Some(false) | None => true,
        },
        "sync" => match data.get("action").and_then(Value::as_str) {
            Some("remote_show") => false,
            Some(_) | None => true,
        },
        _ => false,
    }
}

fn warning_value(warnings: Vec<String>) -> Option<Value> {
    if warnings.is_empty() {
        return None;
    }
    Some(json!({ "warnings": warnings }))
}

fn append_warnings(value: &mut Value, warnings: Vec<String>) {
    if warnings.is_empty() {
        return;
    }
    match value {
        Value::Object(values) => {
            values.insert("warnings".to_string(), json!(warnings));
        }
        _ => {
            let data = std::mem::replace(value, Value::Null);
            *value = json!({
                "data": data,
                "warnings": warnings,
            });
        }
    }
}