gobby-wiki 0.2.0

Gobby wiki CLI shell
use std::io::Write;
use std::path::Path;

use serde::Serialize;

use crate::WikiError;
use crate::scope::ResolvedScope;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultPaths {
    pub directories: &'static [&'static str],
    pub files: Vec<&'static str>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CreatedVaultPaths {
    pub directories: Vec<String>,
    pub files: Vec<String>,
}

const DIRECTORIES: &[&str] = &[
    "raw",
    "raw/assets",
    "wiki",
    "wiki/sources",
    "wiki/concepts",
    "wiki/topics",
    "inbox",
    "outputs",
    "meta",
    "meta/health",
    ".gwiki",
];

pub const DEFAULT_FILES: &[(&str, &str)] = &[
    ("raw/INDEX.md", "# Raw Sources\n\n"),
    ("_index.md", "# Wiki Index\n\n"),
    ("log.md", "# Log\n\n"),
];

pub fn required_paths() -> VaultPaths {
    VaultPaths {
        directories: DIRECTORIES,
        files: DEFAULT_FILES.iter().map(|(path, _)| *path).collect(),
    }
}

pub fn initialize(scope: &ResolvedScope) -> Result<CreatedVaultPaths, WikiError> {
    let root = scope.root();
    let mut created = CreatedVaultPaths {
        directories: Vec::new(),
        files: Vec::new(),
    };
    for directory in DIRECTORIES {
        let path = root.join(directory);
        if !path.exists() {
            created.directories.push((*directory).to_string());
        }
        create_dir(path.as_path())?;
    }

    for (path, contents) in DEFAULT_FILES {
        if ensure_file(root.join(path).as_path(), contents)? {
            created.files.push((*path).to_string());
        }
    }
    let identity = scope.identity();
    let root_path = root.display().to_string();
    let scope_file = root.join(".gwiki/scope.json");
    let scope_json = serde_json::to_string_pretty(&ScopeFile {
        identity: &identity,
        root: &root_path,
    })
    .map_err(|error| WikiError::Json {
        action: "serialize scope file",
        path: Some(scope_file.clone()),
        source: error,
    })?;
    let scope_file_created = !scope_file.exists();
    write_scope_file_atomically(scope_file.as_path(), format!("{scope_json}\n").as_bytes())?;
    if scope_file_created {
        created.files.push(".gwiki/scope.json".to_string());
    }
    Ok(created)
}

pub fn cleanup_created(root: &Path, created: &CreatedVaultPaths) -> Result<(), WikiError> {
    for file in &created.files {
        let path = root.join(file);
        match std::fs::remove_file(&path) {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(source) => {
                return Err(WikiError::Io {
                    action: "remove initialized file",
                    path: Some(path),
                    source,
                });
            }
        }
    }

    for directory in created.directories.iter().rev() {
        let path = root.join(directory);
        match std::fs::remove_dir(&path) {
            Ok(()) => {}
            Err(error)
                if matches!(
                    error.kind(),
                    std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
                ) => {}
            Err(source) => {
                return Err(WikiError::Io {
                    action: "remove initialized directory",
                    path: Some(path),
                    source,
                });
            }
        }
    }

    Ok(())
}

#[derive(Serialize)]
struct ScopeFile<'a> {
    identity: &'a str,
    root: &'a str,
}

fn create_dir(path: &Path) -> Result<(), WikiError> {
    std::fs::create_dir_all(path).map_err(|error| WikiError::Io {
        action: "create directory",
        path: Some(path.to_path_buf()),
        source: error,
    })
}

fn ensure_file(path: &Path, contents: &str) -> Result<bool, WikiError> {
    if let Some(parent) = path.parent() {
        create_dir(parent)?;
    }
    match std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
    {
        Ok(mut file) => {
            if let Err(source) = file.write_all(contents.as_bytes()) {
                let _ = std::fs::remove_file(path);
                return Err(WikiError::Io {
                    action: "write file",
                    path: Some(path.to_path_buf()),
                    source,
                });
            }
            Ok(true)
        }
        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
        Err(source) => Err(WikiError::Io {
            action: "create file",
            path: Some(path.to_path_buf()),
            source,
        }),
    }
}

fn write_scope_file_atomically(path: &Path, contents: &[u8]) -> Result<(), WikiError> {
    if let Some(parent) = path.parent() {
        create_dir(parent)?;
    }
    let temp_path = temp_sibling_path(path);
    let mut file = std::fs::File::create(&temp_path).map_err(|error| WikiError::Io {
        action: "create scope file temp file",
        path: Some(temp_path.clone()),
        source: error,
    })?;
    if let Err(error) = file.write_all(contents) {
        let _ = std::fs::remove_file(&temp_path);
        return Err(WikiError::Io {
            action: "write scope file temp file",
            path: Some(temp_path),
            source: error,
        });
    }
    if let Err(error) = file.sync_all() {
        let _ = std::fs::remove_file(&temp_path);
        return Err(WikiError::Io {
            action: "sync scope file temp file",
            path: Some(temp_path),
            source: error,
        });
    }
    drop(file);
    if let Err(error) = std::fs::rename(&temp_path, path) {
        let _ = std::fs::remove_file(&temp_path);
        return Err(WikiError::Io {
            action: "replace scope file",
            path: Some(path.to_path_buf()),
            source: error,
        });
    }
    sync_parent_dir(path)
}

fn temp_sibling_path(path: &Path) -> std::path::PathBuf {
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("scope.json");
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos())
        .unwrap_or_default();
    path.with_file_name(format!(".{file_name}.{}.{nanos}.tmp", std::process::id()))
}

fn sync_parent_dir(path: &Path) -> Result<(), WikiError> {
    #[cfg(not(unix))]
    {
        let _ = path;
        Ok(())
    }
    #[cfg(unix)]
    {
        let Some(parent) = path.parent() else {
            return Ok(());
        };
        std::fs::File::open(parent)
            .and_then(|dir| dir.sync_all())
            .map_err(|error| WikiError::Io {
                action: "sync scope file directory",
                path: Some(parent.to_path_buf()),
                source: error,
            })
    }
}

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

    #[test]
    fn vault_shape_lists_required_paths() {
        let paths = required_paths();

        assert!(paths.directories.contains(&"raw/assets"));
        assert!(paths.directories.contains(&"wiki/sources"));
        assert!(paths.directories.contains(&"wiki/concepts"));
        assert!(paths.directories.contains(&"wiki/topics"));
        assert!(paths.directories.contains(&"outputs"));
        assert!(paths.directories.contains(&"meta/health"));
        assert!(paths.files.contains(&"raw/INDEX.md"));
        assert!(paths.files.contains(&"_index.md"));
        assert!(paths.files.contains(&"log.md"));
    }

    #[test]
    fn default_files_drive_required_paths_and_contents() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path().join("wiki");
        let scope = ResolvedScope::topic(
            "rust".to_string(),
            root.clone(),
            temp.path().join("wikis.json"),
        );

        initialize(&scope).expect("initialize");
        let required = required_paths();

        assert_eq!(
            required.files,
            DEFAULT_FILES
                .iter()
                .map(|(path, _)| *path)
                .collect::<Vec<_>>()
        );
        for (path, contents) in DEFAULT_FILES {
            assert_eq!(
                std::fs::read_to_string(root.join(path)).expect("read default file"),
                *contents
            );
        }
    }

    #[test]
    fn initialize_overwrites_scope_file() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path().join("wiki");
        let scope = ResolvedScope::topic(
            "rust".to_string(),
            root.clone(),
            temp.path().join("wikis.json"),
        );
        initialize(&scope).expect("initialize once");
        let scope_file = root.join(".gwiki/scope.json");
        std::fs::write(&scope_file, "stale").expect("write stale scope");

        let created = initialize(&scope).expect("initialize twice");

        let contents = std::fs::read_to_string(scope_file).expect("read scope");
        assert!(contents.contains("topic:rust"));
        assert!(!contents.contains("stale"));
        assert!(!created.files.contains(&".gwiki/scope.json".to_string()));
    }

    #[test]
    fn cleanup_created_removes_only_created_vault_paths() {
        let temp = tempfile::tempdir().expect("tempdir");
        let root = temp.path().join("wiki");
        let scope = ResolvedScope::topic(
            "rust".to_string(),
            root.clone(),
            temp.path().join("wikis.json"),
        );
        let created = initialize(&scope).expect("initialize");

        cleanup_created(&root, &created).expect("cleanup created paths");

        for file in created.files {
            assert!(!root.join(file).exists());
        }
        for directory in created.directories {
            assert!(!root.join(directory).exists());
        }
        assert!(root.exists());
    }
}