travelagent-core 1.11.1

Core library for travelagent code review tool
Documentation
//! Shared MCP resource URI parsing and notification mapping.
//!
//! Both MCP surfaces (the `--mcp-alongside` bridge and the standalone
//! `trv-mcp` server) expose the same three review-state resources:
//!
//! - `review://status` — mirrors the `trv_get_review_status` tool output.
//! - `review://diff/{file}` — mirrors `trv_get_diff` for a specific file.
//! - `review://comments` — mirrors the unfiltered `trv_get_comments`.
//!
//! This module centralises the URI scheme so the two surfaces cannot
//! drift on either the parser or the notification-mapping side.
//!
//! # URI parsing
//!
//! `parse_resource_uri("review://status")` → [`ResourceKind::Status`].
//! `parse_resource_uri("review://comments")` → [`ResourceKind::Comments`].
//! `parse_resource_uri("review://diff/src/foo.rs")` → [`ResourceKind::Diff`]
//! with `file = "src/foo.rs"`. Trailing slashes and empty file segments are
//! rejected so an agent that sends `review://diff/` gets a structured error
//! rather than silently getting an empty file lookup.
//!
//! # Notification mapping
//!
//! When session state changes (file content, new comment, review submitted),
//! the drain task emits `notifications/resources/updated` for each affected
//! resource URI. [`resource_uris_for_notify`] returns the list of URIs
//! touched by a given notification so the fan-out loop can emit one standard
//! MCP notification per URI alongside the existing custom `notifications/trv/*`
//! events.

/// Constant URI for the review-status resource.
pub const STATUS_URI: &str = "review://status";

/// Constant URI for the comments resource.
pub const COMMENTS_URI: &str = "review://comments";

/// URI template (RFC 6570-style) advertised via `resources/templates/list`.
/// Matches the concrete URIs produced by [`diff_uri_for_file`].
pub const DIFF_URI_TEMPLATE: &str = "review://diff/{file}";

/// Scheme prefix for all review resources. Kept as a string so parser code
/// stays grep-able and we don't scatter `"review://"` literals.
pub const SCHEME_PREFIX: &str = "review://";

/// Path prefix for diff resources — `review://diff/{file}`.
pub const DIFF_PREFIX: &str = "review://diff/";

/// Parsed resource URI. Exhaustive: if we add a new resource later we want
/// every matcher to break so the parity between the two surfaces stays
/// explicit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceKind {
    /// `review://status`
    Status,
    /// `review://comments`
    Comments,
    /// `review://diff/{file}`
    Diff { file: String },
}

/// Error from [`parse_resource_uri`]. The textual form is intentionally
/// user-facing because both MCP surfaces serialize it into the JSON-RPC
/// error response.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceUriError {
    pub uri: String,
    pub reason: &'static str,
}

impl std::fmt::Display for ResourceUriError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "invalid resource URI {:?}: {}", self.uri, self.reason)
    }
}

impl std::error::Error for ResourceUriError {}

/// Parse a `review://…` URI into a [`ResourceKind`].
///
/// Returns `Err` when:
/// - the scheme doesn't match,
/// - a known path (`status` / `comments`) is followed by extra segments,
/// - a `diff/` URI has no file component or ends with `/`.
pub fn parse_resource_uri(uri: &str) -> Result<ResourceKind, ResourceUriError> {
    let Some(rest) = uri.strip_prefix(SCHEME_PREFIX) else {
        return Err(ResourceUriError {
            uri: uri.to_string(),
            reason: "expected scheme 'review://'",
        });
    };
    match rest {
        "status" => Ok(ResourceKind::Status),
        "comments" => Ok(ResourceKind::Comments),
        other if other.starts_with("diff/") => {
            let file = &other[5..];
            if file.is_empty() {
                return Err(ResourceUriError {
                    uri: uri.to_string(),
                    reason: "diff resource requires a file path",
                });
            }
            Ok(ResourceKind::Diff {
                file: file.to_string(),
            })
        }
        _ => Err(ResourceUriError {
            uri: uri.to_string(),
            reason: "unknown resource path",
        }),
    }
}

/// Build the concrete URI for a given file's diff resource.
///
/// The file path is appended verbatim — no URL-encoding — because our diff
/// file paths are repo-relative POSIX strings. If we ever support paths
/// containing characters that aren't legal in RFC 3986 opaque URIs, switch
/// to percent-encoding here and the parser above.
#[must_use]
pub fn diff_uri_for_file(file: &str) -> String {
    format!("{DIFF_PREFIX}{file}")
}

/// Kinds of session state changes the notify-drain can fan out as
/// `notifications/resources/updated`. Declared here (instead of reusing
/// `travelagent_tui::app::McpNotify`) so the core crate has no upward
/// dependency on the TUI, but the mapping lives alongside the URI parser
/// that consumes it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceNotify<'a> {
    /// A file's diff content changed — usually a live-mode watcher rescan.
    /// `files` is the display-path list; empty means "unknown / all".
    FilesChanged { files: &'a [String] },
    /// A comment was added.
    CommentAdded,
    /// A review was submitted (or exported).
    ReviewSubmitted,
}

/// Return the resource URIs that should receive a `notifications/resources/updated`
/// event for the given change. Order is stable so fan-out tests can assert a
/// deterministic set.
///
/// - `FilesChanged { files }` → `review://status` plus one `review://diff/{file}`
///   per entry. An empty file list produces just `review://status` because we
///   don't know which specific diff resource changed.
/// - `CommentAdded` → `review://comments` and `review://status`
///   (comment count bumps the status counters).
/// - `ReviewSubmitted` → `review://status`.
#[must_use]
pub fn resource_uris_for_notify(notify: &ResourceNotify<'_>) -> Vec<String> {
    match notify {
        ResourceNotify::FilesChanged { files } => {
            let mut uris = Vec::with_capacity(1 + files.len());
            uris.push(STATUS_URI.to_string());
            for f in files.iter() {
                uris.push(diff_uri_for_file(f));
            }
            uris
        }
        ResourceNotify::CommentAdded => {
            vec![COMMENTS_URI.to_string(), STATUS_URI.to_string()]
        }
        ResourceNotify::ReviewSubmitted => vec![STATUS_URI.to_string()],
    }
}

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

    #[test]
    fn parses_status_and_comments() {
        assert_eq!(
            parse_resource_uri(STATUS_URI).unwrap(),
            ResourceKind::Status
        );
        assert_eq!(
            parse_resource_uri(COMMENTS_URI).unwrap(),
            ResourceKind::Comments
        );
    }

    #[test]
    fn parses_diff_uri_with_nested_path() {
        assert_eq!(
            parse_resource_uri("review://diff/src/foo.rs").unwrap(),
            ResourceKind::Diff {
                file: "src/foo.rs".to_string(),
            }
        );
        assert_eq!(
            parse_resource_uri("review://diff/deeply/nested/path/file.rs").unwrap(),
            ResourceKind::Diff {
                file: "deeply/nested/path/file.rs".to_string(),
            }
        );
    }

    #[test]
    fn rejects_unknown_scheme() {
        let err = parse_resource_uri("http://example.com").unwrap_err();
        assert!(err.reason.contains("scheme"));
    }

    #[test]
    fn rejects_unknown_path() {
        let err = parse_resource_uri("review://nope").unwrap_err();
        assert!(err.reason.contains("unknown"));
    }

    #[test]
    fn rejects_empty_diff_file() {
        let err = parse_resource_uri("review://diff/").unwrap_err();
        assert!(err.reason.contains("file"));
    }

    #[test]
    fn diff_uri_round_trips() {
        let uri = diff_uri_for_file("src/main.rs");
        assert_eq!(uri, "review://diff/src/main.rs");
        assert_eq!(
            parse_resource_uri(&uri).unwrap(),
            ResourceKind::Diff {
                file: "src/main.rs".to_string(),
            }
        );
    }

    #[test]
    fn notify_comment_added_updates_comments_and_status() {
        let uris = resource_uris_for_notify(&ResourceNotify::CommentAdded);
        assert_eq!(uris, vec![COMMENTS_URI.to_string(), STATUS_URI.to_string()]);
    }

    #[test]
    fn notify_review_submitted_updates_status() {
        let uris = resource_uris_for_notify(&ResourceNotify::ReviewSubmitted);
        assert_eq!(uris, vec![STATUS_URI.to_string()]);
    }

    #[test]
    fn notify_files_changed_emits_status_plus_per_file_diffs() {
        let files = vec!["src/a.rs".to_string(), "src/b.rs".to_string()];
        let uris = resource_uris_for_notify(&ResourceNotify::FilesChanged { files: &files });
        assert_eq!(
            uris,
            vec![
                STATUS_URI.to_string(),
                "review://diff/src/a.rs".to_string(),
                "review://diff/src/b.rs".to_string(),
            ]
        );
    }

    #[test]
    fn notify_files_changed_with_empty_list_is_status_only() {
        let files: Vec<String> = Vec::new();
        let uris = resource_uris_for_notify(&ResourceNotify::FilesChanged { files: &files });
        assert_eq!(uris, vec![STATUS_URI.to_string()]);
    }
}