face-core 0.1.0

Core grouping, clustering, and paging primitives for the face CLI.
Documentation
//! jq-like path resolution against `serde_json::Value`.
//!
//! Supports dotted segments and bracketed integer indices, e.g.
//! `data.path.text`, `data.lines[0].text`, or `.[0]` for top-level
//! array access. The leading `.` is optional and treated as
//! identity-then-lookup. The bare path `.` resolves to the input
//! value as-is.
//!
//! Quoted segments (e.g. `."key with dot"`) are intentionally **not**
//! supported in this slice — keys containing dots are out of scope
//! until a later slice raises the need.
//!
//! # Error shape
//!
//! [`resolve`] returns `Result<&Value, FaceError>`. The chosen variant
//! is [`FaceError::UnknownItemsPath`] — `path::resolve` does not know
//! whether the path came from `--items` or `--score`, so the
//! `UnknownItemsPath` form is used as the generic shape. Callers that
//! distinguish the two flags re-wrap into [`FaceError::UnknownScorePath`]
//! when appropriate (the score-resolution call site has the context).
//! This is the simpler call shape — callers that don't care about the
//! distinction propagate the error as-is.

use serde_json::Value;

use crate::FaceError;

/// Resolve a jq-like dotted/bracketed path against a [`Value`].
///
/// # Path grammar
///
/// - `.` — identity (returns the input unchanged).
/// - `.foo.bar` — nested object lookup.
/// - `.foo[0]` — array index after a key.
/// - `.[0]` — array index at the root.
/// - `foo.bar` — leading `.` is optional.
/// - `.foo[0].bar[1]` — index chains accepted between segments.
///
/// # Errors
///
/// Returns [`FaceError::UnknownItemsPath`] when the path cannot be
/// resolved. The variant carries the original path string so the user
/// can identify which lookup failed; the variant choice is generic —
/// the score path code site re-wraps to
/// [`FaceError::UnknownScorePath`] when relevant.
///
/// # Examples
///
/// ```
/// use face_core::path;
/// use serde_json::json;
///
/// let v = json!({"data": {"path": {"text": "src/lib.rs"}}});
/// let leaf = path::resolve(&v, "data.path.text").unwrap();
/// assert_eq!(leaf, &json!("src/lib.rs"));
///
/// let identity = path::resolve(&v, ".").unwrap();
/// assert_eq!(identity, &v);
/// ```
pub fn resolve<'v>(value: &'v Value, path: &str) -> Result<&'v Value, FaceError> {
    resolve_inner(value, path).map_err(|_| FaceError::UnknownItemsPath {
        path: path.to_string(),
    })
}

/// Resolve and clone the result, useful when the caller needs an owned
/// [`Value`] (e.g. to feed it across an API boundary).
///
/// # Errors
///
/// See [`resolve`].
///
/// # Examples
///
/// ```
/// use face_core::path;
/// use serde_json::json;
///
/// let v = json!({"hits": [10, 20, 30]});
/// let owned = path::resolve_owned(&v, ".hits[1]").unwrap();
/// assert_eq!(owned, json!(20));
/// ```
pub fn resolve_owned(value: &Value, path: &str) -> Result<Value, FaceError> {
    resolve(value, path).cloned()
}

/// Inner resolver returning a static-string error so the parser logic
/// stays free of allocation. The public wrapper turns the static
/// reason into a [`FaceError`] carrying the original path.
fn resolve_inner<'v>(value: &'v Value, path: &str) -> Result<&'v Value, &'static str> {
    let trimmed = path.strip_prefix('.').unwrap_or(path);
    if trimmed.is_empty() {
        return Ok(value);
    }

    let mut current = value;
    for segment in parse_segments(trimmed)? {
        current = step(current, segment)?;
    }
    Ok(current)
}

/// One parsed path segment.
enum Segment<'a> {
    /// `.foo` — object key lookup.
    Key(&'a str),
    /// `[N]` — array index.
    Index(usize),
}

/// Parse a path body (already stripped of any leading `.`) into segments.
///
/// The grammar is: optional leading `[N]` chain, then identifiers
/// separated by `.` with optional `[N]` suffixes that may chain
/// (`foo[0][1]`). We do not allow whitespace, quoted segments, or
/// negative indices.
fn parse_segments(body: &str) -> Result<Vec<Segment<'_>>, &'static str> {
    let mut out = Vec::new();
    let mut rest = body;

    // Leading `[N]` chain (top-level array index, e.g. `.[1]`).
    while let Some(after_open) = rest.strip_prefix('[') {
        let close = after_open.find(']').ok_or("unterminated `[`")?;
        let index_str = &after_open[..close];
        if index_str.is_empty() {
            return Err("empty array index");
        }
        let index: usize = index_str.parse().map_err(|_| "non-integer array index")?;
        out.push(Segment::Index(index));
        rest = &after_open[close + 1..];
    }

    // Skip a `.` between leading indices and the first key.
    if let Some(after_dot) = rest.strip_prefix('.') {
        if after_dot.is_empty() {
            return Err("trailing `.`");
        }
        rest = after_dot;
    }

    while !rest.is_empty() {
        // Read the key portion up to the next `.` or `[`.
        let key_end = rest.find(['.', '[']).unwrap_or(rest.len());
        let key = &rest[..key_end];
        if key.is_empty() {
            // Two `.` in a row, or a `[` immediately following another `[`.
            return Err("empty segment");
        }
        out.push(Segment::Key(key));
        rest = &rest[key_end..];

        // Read any chained `[N]` indices after the key.
        while let Some(after_open) = rest.strip_prefix('[') {
            let close = after_open.find(']').ok_or("unterminated `[`")?;
            let index_str = &after_open[..close];
            if index_str.is_empty() {
                return Err("empty array index");
            }
            let index: usize = index_str.parse().map_err(|_| "non-integer array index")?;
            out.push(Segment::Index(index));
            rest = &after_open[close + 1..];
        }

        // Consume the separating `.` between segments, if any.
        if let Some(after_dot) = rest.strip_prefix('.') {
            if after_dot.is_empty() {
                return Err("trailing `.`");
            }
            rest = after_dot;
        }
    }

    Ok(out)
}

/// Walk one segment from the current value.
fn step<'v>(current: &'v Value, segment: Segment<'_>) -> Result<&'v Value, &'static str> {
    match segment {
        Segment::Key(key) => current
            .as_object()
            .ok_or("not an object")?
            .get(key)
            .ok_or("missing object key"),
        Segment::Index(idx) => current
            .as_array()
            .ok_or("not an array")?
            .get(idx)
            .ok_or("index out of range"),
    }
}

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

    fn err_path(e: &FaceError) -> &str {
        match e {
            FaceError::UnknownItemsPath { path } => path,
            other => panic!("expected UnknownItemsPath, got {other:?}"),
        }
    }

    #[test]
    fn resolves_identity() {
        let v = json!({"a": 1});
        assert_eq!(resolve(&v, ".").unwrap(), &v);
        assert_eq!(resolve(&v, "").unwrap(), &v);
    }

    #[test]
    fn resolves_nested_dotted() {
        let v = json!({"data": {"path": {"text": "src/cli.rs"}}});
        let got = resolve(&v, "data.path.text").unwrap();
        assert_eq!(got, &json!("src/cli.rs"));
        // Leading `.` is also accepted.
        let got2 = resolve(&v, ".data.path.text").unwrap();
        assert_eq!(got2, &json!("src/cli.rs"));
    }

    #[test]
    fn resolves_array_index() {
        let v = json!({"hits": [{"score": 10}, {"score": 20}]});
        let got = resolve(&v, ".hits[1].score").unwrap();
        assert_eq!(got, &json!(20));
    }

    #[test]
    fn resolves_chained_indices() {
        let v = json!({"matrix": [[1, 2], [3, 4]]});
        let got = resolve(&v, ".matrix[1][0]").unwrap();
        assert_eq!(got, &json!(3));
    }

    #[test]
    fn resolves_top_level_array_index() {
        let v = json!([10, 20, 30]);
        let got = resolve(&v, ".[1]").unwrap();
        assert_eq!(got, &json!(20));
    }

    #[test]
    fn missing_path_errors() {
        let v = json!({"a": 1});
        let err = resolve(&v, ".b").unwrap_err();
        assert_eq!(err_path(&err), ".b");
        let err = resolve(&v, ".a.b").unwrap_err();
        assert_eq!(err_path(&err), ".a.b");
    }

    #[test]
    fn out_of_range_index_errors() {
        let v = json!({"a": [1, 2]});
        let err = resolve(&v, ".a[5]").unwrap_err();
        assert_eq!(err_path(&err), ".a[5]");
    }

    #[test]
    fn rejects_malformed_paths() {
        let v = json!({"a": 1});
        assert!(resolve(&v, "..").is_err());
        assert!(resolve(&v, "a.").is_err());
        assert!(resolve(&v, ".a[").is_err());
        assert!(resolve(&v, ".a[]").is_err());
        assert!(resolve(&v, ".a[abc]").is_err());
    }

    #[test]
    fn resolve_owned_clones() {
        let v = json!({"x": [1, 2, 3]});
        let got = resolve_owned(&v, ".x").unwrap();
        assert_eq!(got, json!([1, 2, 3]));
    }
}