axiomsync 1.0.0

Core data-processing engine for AxiomSync local retrieval runtime.
Documentation
use std::path::Path;
use std::time::Instant;

use chrono::{DateTime, Utc};
use serde_json::json;

use crate::error::{AxiomError, Result};
use crate::models::{MarkdownDocument, MarkdownSaveResult};
use crate::uri::{AxiomUri, Scope};

use super::AxiomSync;

impl AxiomSync {
    pub fn load_document(&self, uri: &str) -> Result<MarkdownDocument> {
        load_editor_document(self, uri, EditorMode::Document)
    }

    pub fn save_document(
        &self,
        uri: &str,
        content: &str,
        expected_etag: Option<&str>,
    ) -> Result<MarkdownSaveResult> {
        save_editor_document(self, uri, content, expected_etag, EditorMode::Document)
    }

    pub fn load_markdown(&self, uri: &str) -> Result<MarkdownDocument> {
        load_editor_document(self, uri, EditorMode::Markdown)
    }

    pub fn save_markdown(
        &self,
        uri: &str,
        content: &str,
        expected_etag: Option<&str>,
    ) -> Result<MarkdownSaveResult> {
        save_editor_document(self, uri, content, expected_etag, EditorMode::Markdown)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditorMode {
    Markdown,
    Document,
}

impl EditorMode {
    const fn load_operation(self) -> &'static str {
        match self {
            Self::Markdown => "markdown.load",
            Self::Document => "document.load",
        }
    }

    const fn save_operation(self) -> &'static str {
        match self {
            Self::Markdown => "markdown.save",
            Self::Document => "document.save",
        }
    }

    const fn label(self) -> &'static str {
        match self {
            Self::Markdown => "markdown",
            Self::Document => "document",
        }
    }

    fn supports_load_extension(self, ext: &str) -> bool {
        match self {
            Self::Markdown => matches!(ext, "md" | "markdown"),
            Self::Document => matches!(
                ext,
                "md" | "markdown" | "json" | "yaml" | "yml" | "jsonl" | "xml" | "txt" | "text"
            ),
        }
    }

    fn supports_save_extension(self, ext: &str) -> bool {
        match self {
            Self::Markdown => matches!(ext, "md" | "markdown"),
            Self::Document => matches!(ext, "md" | "markdown" | "json" | "yaml" | "yml"),
        }
    }

    fn unsupported_load_target_message(self, uri: &AxiomUri) -> String {
        match self {
            Self::Markdown => {
                format!("markdown editor only supports .md/.markdown targets: {uri}")
            }
            Self::Document => {
                format!(
                    "document load supports .md/.markdown/.json/.yaml/.yml/.jsonl/.xml/.txt targets: {uri}"
                )
            }
        }
    }

    fn unsupported_save_target_message(self, uri: &AxiomUri) -> String {
        match self {
            Self::Markdown => {
                format!("markdown editor only supports .md/.markdown targets: {uri}")
            }
            Self::Document => {
                format!("document save supports .md/.markdown/.json/.yaml/.yml targets: {uri}")
            }
        }
    }

    fn format_for_extension(self, ext: &str) -> &'static str {
        match ext {
            "md" | "markdown" => "markdown",
            "json" => "json",
            "yaml" | "yml" => "yaml",
            "jsonl" => "jsonl",
            "xml" => "xml",
            "txt" | "text" => "text",
            _ => "text",
        }
    }

    fn is_editable_extension(self, ext: &str) -> bool {
        match self {
            Self::Markdown => self.supports_save_extension(ext),
            Self::Document => matches!(ext, "md" | "markdown" | "json" | "yaml" | "yml"),
        }
    }
}

fn load_editor_document(app: &AxiomSync, uri: &str, mode: EditorMode) -> Result<MarkdownDocument> {
    let request_id = uuid::Uuid::new_v4().to_string();
    let started = Instant::now();
    let target_uri = uri.to_string();

    let output = (|| -> Result<MarkdownDocument> {
        let uri = AxiomUri::parse(uri)?;
        let ext = validate_editor_target(app, &uri, mode, false)?;
        let uri_gate = app.markdown_gate_for_uri(&uri)?;

        let _guard = uri_gate
            .read()
            .map_err(|_| AxiomError::lock_poisoned("markdown document edit gate"))?;

        let content = app.fs.read(&uri)?;
        let etag = markdown_etag(&content);

        Ok(MarkdownDocument {
            uri: uri.to_string(),
            content,
            etag,
            updated_at: uri_updated_at(app, &uri),
            format: mode.format_for_extension(&ext).to_string(),
            editable: mode.is_editable_extension(&ext),
        })
    })();

    match output {
        Ok(document) => {
            app.log_request_status(
                request_id,
                mode.load_operation(),
                "ok",
                started,
                Some(target_uri),
                Some(json!({
                    "etag": &document.etag,
                    "content_bytes": document.content.len(),
                })),
            );
            Ok(document)
        }
        Err(err) => {
            app.log_request_error(
                request_id,
                mode.load_operation(),
                started,
                Some(target_uri),
                &err,
                None,
            );
            Err(err)
        }
    }
}

fn save_editor_document(
    app: &AxiomSync,
    uri: &str,
    content: &str,
    expected_etag: Option<&str>,
    mode: EditorMode,
) -> Result<MarkdownSaveResult> {
    let request_id = uuid::Uuid::new_v4().to_string();
    let started = Instant::now();
    let target_uri = uri.to_string();
    let content_bytes = content.len();

    let output = (|| -> Result<MarkdownSaveResult> {
        let uri = AxiomUri::parse(uri)?;
        let ext = validate_editor_target(app, &uri, mode, true)?;
        validate_editor_content(mode, &ext, content)?;
        let parent_uri = uri.parent().ok_or_else(|| {
            AxiomError::Validation(format!("{} target must not be a scope root", mode.label()))
        })?;
        let uri_gate = app.markdown_gate_for_uri(&uri)?;

        let _guard = uri_gate
            .write()
            .map_err(|_| AxiomError::lock_poisoned("markdown document edit gate"))?;

        let previous = app.fs.read(&uri)?;
        let current_etag = markdown_etag(&previous);
        if let Some(expected_etag) = expected_etag
            && current_etag != expected_etag
        {
            return Err(AxiomError::Conflict(format!("etag mismatch for {uri}")));
        }

        let save_started = Instant::now();
        app.fs.write_atomic(&uri, content, false)?;
        let save_ms = save_started.elapsed().as_millis();

        let reindex_started = Instant::now();
        if let Err(reindex_err) = app.reindex_document_with_ancestors(&uri) {
            let rollback_write = app.fs.write_atomic(&uri, &previous, false);
            let rollback_reindex = if rollback_write.is_ok() {
                app.reindex_document_with_ancestors(&uri).err()
            } else {
                None
            };
            let rollback_write_status = rollback_write
                .as_ref()
                .map_or_else(|err| format!("err:{err}"), |()| "ok".to_string());
            let rollback_reindex_status = rollback_reindex
                .as_ref()
                .map_or_else(|| "ok_or_skipped".to_string(), |err| format!("err:{err}"));
            let label = mode.label();
            return Err(AxiomError::Internal(format!(
                "{label} save failed during reindex for {uri}: reindex_err={reindex_err}; rollback_write={rollback_write_status}; rollback_reindex={rollback_reindex_status}",
            )));
        }
        let reindex_ms = reindex_started.elapsed().as_millis();

        let committed = app.fs.read(&uri)?;
        Ok(MarkdownSaveResult {
            uri: uri.to_string(),
            etag: markdown_etag(&committed),
            updated_at: uri_updated_at(app, &uri),
            reindexed_root: parent_uri.to_string(),
            save_ms,
            reindex_ms,
        })
    })();

    match output {
        Ok(saved) => {
            app.log_request_status(
                request_id,
                mode.save_operation(),
                "ok",
                started,
                Some(target_uri),
                Some(json!({
                    "etag": &saved.etag,
                    "content_bytes": content_bytes,
                    "save_ms": saved.save_ms,
                    "reindex_ms": saved.reindex_ms,
                    "total_ms": started.elapsed().as_millis(),
                    "reindexed_root": &saved.reindexed_root,
                })),
            );
            Ok(saved)
        }
        Err(err) => {
            app.log_request_error(
                request_id,
                mode.save_operation(),
                started,
                Some(target_uri),
                &err,
                Some(json!({
                    "content_bytes": content_bytes,
                    "expected_etag_provided": expected_etag.is_some(),
                })),
            );
            Err(err)
        }
    }
}

fn validate_editor_target(
    app: &AxiomSync,
    uri: &AxiomUri,
    mode: EditorMode,
    for_save: bool,
) -> Result<String> {
    if !matches!(
        uri.scope(),
        Scope::Resources | Scope::User | Scope::Agent | Scope::Session
    ) {
        return Err(AxiomError::PermissionDenied(format!(
            "{} editor does not allow scope: {}",
            mode.label(),
            uri.scope()
        )));
    }

    if !app.fs.exists(uri) {
        return Err(AxiomError::NotFound(uri.to_string()));
    }
    if app.fs.is_dir(uri) {
        return Err(AxiomError::Validation(format!(
            "{} target must be a file: {}",
            mode.label(),
            uri
        )));
    }

    let name = uri.last_segment().ok_or_else(|| {
        AxiomError::Validation(format!(
            "{} target must include a filename: {uri}",
            mode.label()
        ))
    })?;
    if name == ".abstract.md" || name == ".overview.md" {
        return Err(AxiomError::PermissionDenied(format!(
            "{} editor cannot modify generated tier file: {}",
            mode.label(),
            uri
        )));
    }

    let ext = Path::new(name)
        .extension()
        .and_then(|x| x.to_str())
        .unwrap_or_default()
        .to_ascii_lowercase();
    let supported = if for_save {
        mode.supports_save_extension(&ext)
    } else {
        mode.supports_load_extension(&ext)
    };
    if !supported {
        let message = if for_save {
            mode.unsupported_save_target_message(uri)
        } else {
            mode.unsupported_load_target_message(uri)
        };
        return Err(AxiomError::Validation(message));
    }
    Ok(ext)
}

fn validate_editor_content(mode: EditorMode, ext: &str, content: &str) -> Result<()> {
    if mode == EditorMode::Document {
        if ext == "json" {
            serde_json::from_str::<serde_json::Value>(content).map_err(|err| {
                AxiomError::Validation(format!("invalid json content for document save: {err}"))
            })?;
        } else if ext == "yaml" || ext == "yml" {
            serde_norway::from_str::<serde_norway::Value>(content).map_err(|err| {
                AxiomError::Validation(format!("invalid yaml content for document save: {err}"))
            })?;
        }
    }
    Ok(())
}

fn markdown_etag(content: &str) -> String {
    blake3::hash(content.as_bytes()).to_hex().to_string()
}

fn uri_updated_at(app: &AxiomSync, uri: &AxiomUri) -> String {
    let path = app.fs.resolve_uri(uri);
    let modified = std::fs::metadata(path)
        .and_then(|meta| meta.modified())
        .map(DateTime::<Utc>::from)
        .map(|dt| dt.to_rfc3339());
    modified.unwrap_or_else(|_| Utc::now().to_rfc3339())
}