jacquardSnapshot

← snapshot

9461 bytes
//! History reads: log, snapshot detail, tree browsing, blobs, diffs.

use std::collections::{BTreeSet, VecDeque};

use axum::Json;
use axum::extract::{Path, Query, State};
use jac_core::{SnapshotId, TreeId};
use jac_object::{ObjectStore as _, TreeNode};
use jac_repo::RefName;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::dto::{self, FileEntryDto, SnapshotDto, TreeEntryDto};
use crate::error::ApiError;
use crate::routes::with_repo;
use crate::state::{RepoEntry, SharedState};

#[derive(Debug, Deserialize)]
pub(crate) struct LogQuery {
    pub r#ref: String,
    #[serde(default)]
    pub limit: Option<usize>,
}

/// `GET /api/repos/{slug}/log?ref=&limit=` — history reachable from a ref's
/// head. Every entry carries its parents, so the caller can draw the DAG.
pub(crate) async fn log(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Query(query): Query<LogQuery>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let name = RefName::parse(&query.r#ref)?;
        let head = entry
            .repo
            .head(&name)
            .ok_or_else(|| ApiError::not_found(format!("ref `{}` is unbound", name.as_str())))?;
        let limit = query.limit.unwrap_or(100).min(1000);

        let mut seen = BTreeSet::new();
        let mut queue = VecDeque::from([head]);
        let mut entries = Vec::new();
        while let Some(id) = queue.pop_front() {
            if !seen.insert(id) {
                continue;
            }
            let snapshot = entry.repo.store().snapshot(id)?;
            for parent in &snapshot.parents {
                queue.push_back(*parent);
            }
            entries.push(SnapshotDto::of(id, snapshot, entry));
            if entries.len() >= limit {
                break;
            }
        }
        // Newest first. The sort is stable and keyed on the timestamp alone,
        // so commits sharing a millisecond keep BFS order — head before its
        // parents — instead of an arbitrary id order.
        entries.sort_by(|a, b| b.at.cmp(&a.at));
        Ok(Json(json!({
            "ref": name.as_str(),
            "head": head.digest().to_hex().as_str(),
            "entries": entries,
        })))
    })
}

/// `GET /api/repos/{slug}/snapshots/{hex}` — one snapshot, with its full
/// recursive file listing and the paths changed from its first parent.
pub(crate) async fn detail(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_snapshot_id(&hex)?;
        let snapshot = entry.repo.store().snapshot(id)?.clone();
        let mut files = Vec::new();
        walk_files(entry, snapshot.tree, "", &mut files)?;

        let changed_from_parent: Vec<String> = match snapshot.parents.first() {
            Some(parent) => entry
                .repo
                .changed_between(*parent, id)?
                .iter()
                .map(|p| p.as_str().to_owned())
                .collect(),
            None => files.iter().map(|f| f.path.clone()).collect(),
        };

        Ok(Json(json!({
            "snapshot": SnapshotDto::of(id, &snapshot, entry),
            "files": files,
            "changed_from_parent": changed_from_parent,
        })))
    })
}

/// Recursively lists every file under `tree`, building slash-joined paths.
fn walk_files(
    entry: &RepoEntry,
    tree: TreeId,
    prefix: &str,
    out: &mut Vec<FileEntryDto>,
) -> Result<(), ApiError> {
    let tree = entry.repo.store().tree(tree)?.clone();
    for item in tree.entries() {
        let path = if prefix.is_empty() {
            item.name.as_str().to_owned()
        } else {
            format!("{prefix}/{}", item.name.as_str())
        };
        match item.node {
            TreeNode::Blob(id) => {
                let size = entry.repo.store().blob(id)?.as_bytes().len();
                out.push(FileEntryDto {
                    path,
                    blob: id.digest().to_hex().as_str().to_owned(),
                    size,
                });
            }
            TreeNode::Tree(id) => walk_files(entry, id, &path, out)?,
            // An unknown node kind is listed as nothing rather than failing
            // the whole listing.
            _ => {}
        }
    }
    Ok(())
}

#[derive(Debug, Deserialize)]
pub(crate) struct TreeQuery {
    #[serde(default)]
    pub path: Option<String>,
}

/// `GET /api/repos/{slug}/snapshots/{hex}/tree?path=` — one directory level.
pub(crate) async fn tree(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
    Query(query): Query<TreeQuery>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_snapshot_id(&hex)?;
        let mut tree_id = entry.repo.store().snapshot(id)?.tree;

        // Descend to the requested directory, segment by segment.
        let path = query.path.as_deref().unwrap_or("");
        if !path.is_empty() {
            let repo_path = dto::parse_repo_path(path)?;
            for segment in repo_path.segments() {
                let tree = entry.repo.store().tree(tree_id)?;
                let next = tree
                    .entries()
                    .iter()
                    .find(|e| e.name.as_str() == segment)
                    .ok_or_else(|| ApiError::not_found(format!("no entry `{segment}`")))?;
                match next.node {
                    TreeNode::Tree(sub) => tree_id = sub,
                    _ => {
                        return Err(ApiError::invalid(format!("`{segment}` is not a directory")));
                    }
                }
            }
        }

        let tree = entry.repo.store().tree(tree_id)?.clone();
        let mut entries = Vec::new();
        for item in tree.entries() {
            match item.node {
                TreeNode::Blob(id) => {
                    let size = entry.repo.store().blob(id)?.as_bytes().len();
                    entries.push(TreeEntryDto {
                        name: item.name.as_str().to_owned(),
                        kind: "blob",
                        id: id.digest().to_hex().as_str().to_owned(),
                        size: Some(size),
                    });
                }
                TreeNode::Tree(id) => entries.push(TreeEntryDto {
                    name: item.name.as_str().to_owned(),
                    kind: "tree",
                    id: id.digest().to_hex().as_str().to_owned(),
                    size: None,
                }),
                _ => {}
            }
        }
        Ok(Json(json!({ "path": path, "entries": entries })))
    })
}

/// `GET /api/repos/{slug}/blobs/{hex}` — blob content.
pub(crate) async fn blob(
    State(state): State<SharedState>,
    Path((slug, hex)): Path<(String, String)>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let id = dto::parse_blob_id(&hex)?;
        let blob = entry.repo.store().blob(id)?;
        let bytes = blob.as_bytes();
        let (content, binary) = match core::str::from_utf8(bytes) {
            Ok(text) => (Some(text.to_owned()), false),
            Err(_) => (None, true),
        };
        Ok(Json(json!({
            "id": id.digest().to_hex().as_str(),
            "size": bytes.len(),
            "content": content,
            "binary": binary,
        })))
    })
}

#[derive(Debug, Deserialize)]
pub(crate) struct DiffQuery {
    pub from: String,
    pub to: String,
}

/// `GET /api/repos/{slug}/diff?from=&to=` — paths that differ between two
/// snapshots' trees.
pub(crate) async fn diff(
    State(state): State<SharedState>,
    Path(slug): Path<String>,
    Query(query): Query<DiffQuery>,
) -> Result<Json<Value>, ApiError> {
    with_repo(&state, &slug, |entry| {
        let from = dto::parse_snapshot_id(&query.from)?;
        let to = dto::parse_snapshot_id(&query.to)?;
        let changed: Vec<String> = entry
            .repo
            .changed_between(from, to)?
            .iter()
            .map(|p| p.as_str().to_owned())
            .collect();
        Ok(Json(json!({ "changed": changed })))
    })
}

/// Whether `descendant` reaches `ancestor` through parent edges. The engine
/// keeps its own copy private; this read-only walk serves the dry-run
/// preview.
pub(crate) fn descends_from(
    entry: &RepoEntry,
    descendant: SnapshotId,
    ancestor: SnapshotId,
) -> Result<bool, ApiError> {
    if descendant == ancestor {
        return Ok(true);
    }
    let mut seen = BTreeSet::new();
    let mut queue = VecDeque::from([descendant]);
    while let Some(id) = queue.pop_front() {
        if id == ancestor {
            return Ok(true);
        }
        if !seen.insert(id) {
            continue;
        }
        for parent in &entry.repo.store().snapshot(id)?.parents {
            queue.push_back(*parent);
        }
    }
    Ok(false)
}

/// Every file path in a snapshot, as a set — the blast radius of promoting
/// into an unbound ref.
pub(crate) fn all_paths(
    entry: &RepoEntry,
    id: SnapshotId,
) -> Result<BTreeSet<jac_core::RepoPath>, ApiError> {
    let tree = entry.repo.store().snapshot(id)?.tree;
    let mut files = Vec::new();
    walk_files(entry, tree, "", &mut files)?;
    files
        .iter()
        .map(|f| dto::parse_repo_path(&f.path))
        .collect()
}