gobby-wiki 0.2.0

Gobby wiki CLI shell
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};

use crate::{ScopeIdentity, WikiError};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogEntry {
    pub timestamp: String,
    pub scope: ScopeIdentity,
    pub action: String,
    pub summary: String,
    pub artifacts: Vec<PathBuf>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogWriteReport {
    pub scope_log: PathBuf,
    pub global_log: Option<PathBuf>,
}

pub fn append_logs(
    scope_root: &Path,
    global_hub_root: Option<&Path>,
    entry: &LogEntry,
) -> Result<LogWriteReport, WikiError> {
    let scope_log = scope_root.join("log.md");
    append_log(&scope_log, entry)?;

    // Scope log is the source of truth for command chronology; mirror to the
    // global log only after the scope append succeeds.
    let global_log = global_hub_root
        .map(|root| root.join("log.md"))
        .map(|path| {
            if !same_log_path(&scope_log, &path) {
                append_log(&path, entry)?;
            }
            Ok::<PathBuf, WikiError>(path)
        })
        .transpose()?;

    Ok(LogWriteReport {
        scope_log,
        global_log,
    })
}

fn append_log(path: &Path, entry: &LogEntry) -> Result<(), WikiError> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|error| WikiError::Io {
            action: "create log directory",
            path: Some(parent.to_path_buf()),
            source: error,
        })?;
    }

    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(|error| WikiError::Io {
            action: "open log",
            path: Some(path.to_path_buf()),
            source: error,
        })?;

    let write_header = file
        .metadata()
        .map(|metadata| metadata.len() == 0)
        .unwrap_or(false);
    if write_header {
        file.write_all(b"# Log\n\n")
            .map_err(|error| WikiError::Io {
                action: "write log",
                path: Some(path.to_path_buf()),
                source: error,
            })?;
    }

    file.write_all(render_entry(entry).as_bytes())
        .map_err(|error| WikiError::Io {
            action: "write log",
            path: Some(path.to_path_buf()),
            source: error,
        })
}

fn render_entry(entry: &LogEntry) -> String {
    let mut rendered = format!(
        "## {} - {}\nScope: {}\n\n{}\n",
        entry.timestamp, entry.action, entry.scope, entry.summary
    );
    if !entry.artifacts.is_empty() {
        rendered.push_str("\nArtifacts:\n");
        for artifact in &entry.artifacts {
            rendered.push_str("- ");
            rendered.push_str(&artifact.display().to_string());
            rendered.push('\n');
        }
    }
    rendered.push('\n');
    rendered
}

fn same_log_path(left: &Path, right: &Path) -> bool {
    // Compare after resolving existing parents; append_logs relies on this
    // before writing so scope/global aliases do not receive duplicate entries.
    let resolved_left = resolved_log_path(left);
    let resolved_right = resolved_log_path(right);
    resolved_left == resolved_right || same_file_identity(&resolved_left, &resolved_right)
}

#[cfg(unix)]
fn same_file_identity(left: &Path, right: &Path) -> bool {
    use std::os::unix::fs::MetadataExt;

    let (Ok(left), Ok(right)) = (std::fs::metadata(left), std::fs::metadata(right)) else {
        return false;
    };
    left.dev() == right.dev() && left.ino() == right.ino()
}

#[cfg(windows)]
fn same_file_identity(left: &Path, right: &Path) -> bool {
    use std::os::windows::fs::MetadataExt;

    let (Ok(left), Ok(right)) = (std::fs::metadata(left), std::fs::metadata(right)) else {
        return false;
    };
    left.volume_serial_number() == right.volume_serial_number()
        && left.file_index() == right.file_index()
}

#[cfg(not(any(unix, windows)))]
fn same_file_identity(_left: &Path, _right: &Path) -> bool {
    false
}

fn resolved_log_path(path: &Path) -> PathBuf {
    if let Ok(resolved) = path.canonicalize() {
        return resolved;
    }

    resolve_log_path_fallback(path)
}

fn resolve_log_path_fallback(path: &Path) -> PathBuf {
    if let (Some(parent), Some(file_name)) = (path.parent(), path.file_name())
        && let Ok(parent) = parent.canonicalize()
    {
        return parent.join(file_name);
    }
    path.to_path_buf()
}

#[cfg(test)]
mod tests {
    use std::fs;

    use super::*;

    #[test]
    fn writes_scope_and_global_logs() {
        let temp = tempfile::tempdir().expect("tempdir");
        let scope_root = temp.path().join("scope");
        let hub_root = temp.path().join("hub");
        fs::create_dir_all(&scope_root).expect("scope root");
        fs::create_dir_all(&hub_root).expect("hub root");

        let entry = LogEntry {
            timestamp: "2026-05-29T19:00:00Z".to_string(),
            scope: ScopeIdentity::topic("rust"),
            action: "query".to_string(),
            summary: "Answered ownership question".to_string(),
            artifacts: vec!["outputs/query-ownership.md".into()],
        };

        let report = append_logs(&scope_root, Some(&hub_root), &entry).expect("logs are appended");

        assert_eq!(report.scope_log, scope_root.join("log.md"));
        assert_eq!(report.global_log, Some(hub_root.join("log.md")));

        let scope_log = fs::read_to_string(scope_root.join("log.md")).expect("scope log");
        assert!(scope_log.contains("## 2026-05-29T19:00:00Z - query"));
        assert!(scope_log.contains("Scope: topic:rust"));
        assert!(scope_log.contains("Answered ownership question"));
        assert!(scope_log.contains("outputs/query-ownership.md"));

        let global_log = fs::read_to_string(hub_root.join("log.md")).expect("global log");
        assert_eq!(global_log, scope_log);
    }

    #[test]
    fn does_not_append_twice_when_scope_and_global_logs_match() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path().join("hub");
        fs::create_dir_all(&root).expect("hub root");

        let entry = LogEntry {
            timestamp: "2026-05-29T19:00:00Z".to_string(),
            scope: ScopeIdentity::topic("rust"),
            action: "query".to_string(),
            summary: "Answered ownership question".to_string(),
            artifacts: vec![],
        };

        let report = append_logs(&root, Some(&root), &entry).expect("logs are appended once");

        assert_eq!(report.scope_log, root.join("log.md"));
        assert_eq!(report.global_log, Some(root.join("log.md")));
        let log = fs::read_to_string(root.join("log.md")).expect("log written");
        assert_eq!(log.matches("Answered ownership question").count(), 1);
    }
}