Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-mcp/src/tools.rs

//! **The allowlist**: every tool this connector answers, its arguments, and
//! the ONE control-plane call each becomes.
//!
//! The list is fixed at compile time ([`TOOLS`]). A name that is not on it is
//! refused by name; a write tool is refused unless the connection holds the
//! write grant; an argument that is not declared is refused; and no argument
//! anywhere is an address: there is no URL, host, endpoint or command among
//! them, and a value that carries a scheme (`://`) is refused outright
//! ([`no_address`]). What the tool reaches is decided by its NAME, never by
//! what an agent puts in it.

use serde_json::{json, Map, Value};

/// What a connection may do. Read is the default; write is a separate,
/// explicit grant the person ticks at their passkey touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
    /// The read tools only.
    Read,
    /// The read tools and the write tools.
    ReadWrite,
}

impl Scope {
    /// The OAuth scope string.
    pub fn as_str(self) -> &'static str {
        match self {
            Scope::Read => SCOPE_READ,
            Scope::ReadWrite => "gunnar:read gunnar:write",
        }
    }

    /// Does this scope cover `tool`?
    pub fn allows(self, tool: &Tool) -> bool {
        !tool.write || self == Scope::ReadWrite
    }
}

/// The read scope.
pub const SCOPE_READ: &str = "gunnar:read";
/// The write scope: a separate grant, off unless the person grants it.
pub const SCOPE_WRITE: &str = "gunnar:write";

/// One argument of a tool.
#[derive(Debug)]
pub struct Arg {
    /// Its name in `arguments`.
    pub name: &'static str,
    /// What kind of value it takes.
    pub kind: Kind,
    /// Must it be given?
    pub required: bool,
    /// What the agent is told it is.
    pub about: &'static str,
}

/// The kind of value an argument takes. There is no kind for an address.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
    /// A namespace or repository name: letters, digits, `.`, `_`, `-`.
    Name,
    /// A full hex object id (40 or 64 characters).
    Oid,
    /// A revision: a ref name (`refs/heads/main`, `main`, `HEAD`) or a hex oid.
    Rev,
    /// A path inside a repository's tree.
    Path,
    /// Free text: a search pattern, a filter, a comment.
    Text,
    /// A positive whole number.
    Number,
    /// A list of paths.
    Paths,
    /// One of a fixed set of words.
    OneOf(&'static [&'static str]),
}

/// One tool on the allowlist.
#[derive(Debug)]
pub struct Tool {
    /// The tool's name, as the agent calls it.
    pub name: &'static str,
    /// A short human title.
    pub title: &'static str,
    /// What it does, for the agent.
    pub about: &'static str,
    /// The one control-plane call it becomes (said in the handback and the docs).
    pub call: &'static str,
    /// True for a tool that changes something: listed and answered only under
    /// the write grant.
    pub write: bool,
    /// Its arguments.
    pub args: &'static [Arg],
}

const NAMESPACE: Arg = Arg {
    name: "namespace",
    kind: Kind::Name,
    required: true,
    about: "The repository's namespace (the part before the slash).",
};
const REPO: Arg = Arg {
    name: "repo",
    kind: Kind::Name,
    required: true,
    about: "The repository's name (the part after the slash).",
};

/// **The allowlist.** Each entry is one existing control-plane call.
pub const TOOLS: &[Tool] = &[
    Tool {
        name: "list_repositories",
        title: "List repositories",
        about: "List the repositories you can see on this Gunnar, with their visibility and default branch. Optionally filter by a part of `namespace/name`.",
        call: "Repos.List",
        write: false,
        args: &[Arg { name: "query", kind: Kind::Text, required: false, about: "Only repositories whose `namespace/name` contains this text." }],
    },
    Tool {
        name: "list_refs",
        title: "List branches and tags",
        about: "List a repository's branches and tags with the commit each points at.",
        call: "Refs.List",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "prefix", kind: Kind::OneOf(&["refs/heads/", "refs/tags/", ""]), required: false, about: "Only refs under this prefix: `refs/heads/` (branches), `refs/tags/` (tags), or empty for all." }],
    },
    Tool {
        name: "list_commits",
        title: "List commits",
        about: "List commits, newest first, from a revision (default: the default branch), optionally only those that changed a path. Each commit names its tree oid, which `browse_tree` opens.",
        call: "Refs.Log",
        write: false,
        args: &[
            NAMESPACE,
            REPO,
            Arg { name: "rev", kind: Kind::Rev, required: false, about: "A branch, tag, `HEAD` or full commit oid. Empty: the default branch." },
            Arg { name: "path", kind: Kind::Path, required: false, about: "Only commits that changed this file or directory." },
            Arg { name: "limit", kind: Kind::Number, required: false, about: "How many commits (1 to 100, default 20)." },
        ],
    },
    Tool {
        name: "browse_tree",
        title: "Browse a tree",
        about: "List one directory (a tree object) by its oid: each entry's name, kind and oid. Start from a commit's tree oid (from `list_commits`); open a sub-directory with its oid, and a file with `read_file`.",
        call: "Refs.ReadObject",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "oid", kind: Kind::Oid, required: true, about: "The tree's full hex oid." }],
    },
    Tool {
        name: "read_file",
        title: "Read a file",
        about: "Read one file (a blob object) by its oid, as text. Large files are cut at 256 KiB, and a binary file is described, not shown.",
        call: "Refs.ReadObject",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "oid", kind: Kind::Oid, required: true, about: "The file's full hex blob oid (from `browse_tree`)." }],
    },
    Tool {
        name: "search_code",
        title: "Search code",
        about: "Search one revision's files for a literal string. Answers the path, line number and line of each match.",
        call: "Refs.Search",
        write: false,
        args: &[
            NAMESPACE,
            REPO,
            Arg { name: "pattern", kind: Kind::Text, required: true, about: "The literal text to find." },
            Arg { name: "rev", kind: Kind::Rev, required: false, about: "The revision to search. Empty: the default branch." },
        ],
    },
    Tool {
        name: "diff",
        title: "Compare two revisions",
        about: "List the files that differ between two revisions, with the kind of change, the line counts and the old and new blob oids (read them with `read_file`).",
        call: "Refs.Diff",
        write: false,
        args: &[
            NAMESPACE,
            REPO,
            Arg { name: "from", kind: Kind::Rev, required: true, about: "The older revision (a ref name or oid)." },
            Arg { name: "to", kind: Kind::Rev, required: true, about: "The newer revision (a ref name or oid)." },
            Arg { name: "paths", kind: Kind::Paths, required: false, about: "Only changes under these path prefixes." },
        ],
    },
    Tool {
        name: "list_issues",
        title: "List issues",
        about: "List a repository's issues: number, state, title and author.",
        call: "Forge.ListIssues",
        write: false,
        args: &[NAMESPACE, REPO],
    },
    Tool {
        name: "get_issue",
        title: "Read an issue",
        about: "Read one issue: its title, state, author and text.",
        call: "Forge.GetIssue",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "number", kind: Kind::Number, required: true, about: "The issue's number." }],
    },
    Tool {
        name: "list_merge_requests",
        title: "List merge requests",
        about: "List a repository's merge requests: number, state, title, source and target branch.",
        call: "PullRequests.List",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "state", kind: Kind::OneOf(&["open", "merged", "closed", "all"]), required: false, about: "Only merge requests in this state (default: all)." }],
    },
    Tool {
        name: "get_merge_request",
        title: "Read a merge request",
        about: "Read one merge request: title, state, branches, the commits they point at, and its description. Compare the two with `diff`.",
        call: "PullRequests.Get",
        write: false,
        args: &[NAMESPACE, REPO, Arg { name: "number", kind: Kind::Number, required: true, about: "The merge request's number." }],
    },
    Tool {
        name: "comment_on_issue",
        title: "Comment on an issue",
        about: "Add a comment to an issue, signed as you. Only with the write grant.",
        call: "Forge.Append",
        write: true,
        args: &[
            NAMESPACE,
            REPO,
            Arg { name: "number", kind: Kind::Number, required: true, about: "The issue's number." },
            Arg { name: "body", kind: Kind::Text, required: true, about: "The comment's text." },
        ],
    },
];

/// The tool named `name`, if it is on the allowlist.
pub fn tool(name: &str) -> Option<&'static Tool> {
    TOOLS.iter().find(|t| t.name == name)
}

/// One tool call, typed: what the [`crate::Backend`] answers with ONE
/// control-plane call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Call {
    /// `Repos.List`.
    ListRepositories { query: String },
    /// `Refs.List`.
    ListRefs { repo: RepoName, prefix: String },
    /// `Refs.Log`.
    ListCommits {
        repo: RepoName,
        rev: String,
        path: String,
        limit: u32,
    },
    /// `Refs.ReadObject` of a tree.
    BrowseTree { repo: RepoName, oid: String },
    /// `Refs.ReadObject` of a blob.
    ReadFile {
        repo: RepoName,
        oid: String,
        max_bytes: u64,
    },
    /// `Refs.Search`.
    SearchCode {
        repo: RepoName,
        pattern: String,
        rev: String,
    },
    /// `Refs.Diff`.
    Diff {
        repo: RepoName,
        from: String,
        to: String,
        paths: Vec<String>,
    },
    /// `Forge.ListIssues`.
    ListIssues { repo: RepoName },
    /// `Forge.GetIssue`.
    GetIssue { repo: RepoName, number: u64 },
    /// `PullRequests.List`; `state` is `open`, `merged`, `closed` or `all`.
    ListMergeRequests { repo: RepoName, state: String },
    /// `PullRequests.Get`.
    GetMergeRequest { repo: RepoName, number: u64 },
    /// `Forge.Append` of an issue comment (write grant only).
    CommentOnIssue {
        repo: RepoName,
        number: u64,
        body: String,
    },
}

impl Call {
    /// The repository the call is about, if any.
    pub fn repo(&self) -> Option<&RepoName> {
        match self {
            Call::ListRepositories { .. } => None,
            Call::ListRefs { repo, .. }
            | Call::ListCommits { repo, .. }
            | Call::BrowseTree { repo, .. }
            | Call::ReadFile { repo, .. }
            | Call::SearchCode { repo, .. }
            | Call::Diff { repo, .. }
            | Call::ListIssues { repo }
            | Call::GetIssue { repo, .. }
            | Call::ListMergeRequests { repo, .. }
            | Call::GetMergeRequest { repo, .. }
            | Call::CommentOnIssue { repo, .. } => Some(repo),
        }
    }
}

/// `namespace/repo`, both checked to be plain names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoName {
    /// The namespace.
    pub namespace: String,
    /// The repository.
    pub name: String,
}

impl std::fmt::Display for RepoName {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.namespace, self.name)
    }
}

/// The most a `read_file` answer carries.
pub const READ_FILE_MAX: u64 = 256 * 1024;
/// The most commits one `list_commits` answers.
pub const LOG_MAX: u32 = 100;
/// The longest free text an argument may carry.
const TEXT_MAX: usize = 4096;
/// The longest search pattern.
const PATTERN_MAX: usize = 256;

/// Why a call was not made.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Refused {
    /// The name is not on the allowlist (JSON-RPC -32602, "Unknown tool").
    Unknown(String),
    /// The tool writes and the connection holds no write grant.
    NeedsWrite(&'static str),
    /// An argument was missing, undeclared or malformed.
    Argument(String),
}

impl Refused {
    /// The sentence the agent is given.
    pub fn words(&self) -> String {
        match self {
            Refused::Unknown(name) => format!("Unknown tool: {name}. This connector answers only its fixed list of tools."),
            Refused::NeedsWrite(name) => format!("The tool {name} changes something, and this connection holds only the read grant. Connect again and grant write access to use it."),
            Refused::Argument(why) => why.clone(),
        }
    }
}

/// A tool name, made safe to say back: printable ASCII, at most 64 characters.
pub fn said_name(name: &str) -> String {
    let mut out: String = name
        .chars()
        .filter(|c| c.is_ascii_graphic())
        .take(64)
        .collect();
    if out.is_empty() {
        out.push_str("(empty)");
    }
    out
}

/// **Parse one `tools/call`** into a [`Call`], or say why not.
///
/// # Errors
///
/// [`Refused`]: an unknown name, a write tool without the write grant, or an
/// argument that is missing, undeclared, malformed or an address.
pub fn parse(name: &str, arguments: Option<&Value>, scope: Scope) -> Result<Call, Refused> {
    let tool = tool(name).ok_or_else(|| Refused::Unknown(said_name(name)))?;
    if !scope.allows(tool) {
        return Err(Refused::NeedsWrite(tool.name));
    }
    let empty = Map::new();
    let args = match arguments {
        None | Some(Value::Null) => &empty,
        Some(Value::Object(map)) => map,
        Some(_) => {
            return Err(Refused::Argument(
                "`arguments` must be an object".to_owned(),
            ))
        }
    };
    for key in args.keys() {
        if !tool.args.iter().any(|a| a.name == key) {
            return Err(Refused::Argument(format!(
                "{} takes no argument named {:?}; it takes {}",
                tool.name,
                said_name(key),
                tool.args
                    .iter()
                    .map(|a| a.name)
                    .collect::<Vec<_>>()
                    .join(", ")
            )));
        }
    }
    let got = Got { tool, args };
    let repo = || -> Result<RepoName, Refused> {
        Ok(RepoName {
            namespace: got_str(tool, args, "namespace")?,
            name: got_str(tool, args, "repo")?,
        })
    };
    let call = match tool.name {
        "list_repositories" => Call::ListRepositories {
            query: got.text("query")?,
        },
        "list_refs" => Call::ListRefs {
            repo: repo()?,
            prefix: got.text("prefix")?,
        },
        "list_commits" => {
            let limit = got.number("limit")?.unwrap_or(20);
            if limit == 0 || limit > u64::from(LOG_MAX) {
                return Err(Refused::Argument(format!(
                    "limit must be between 1 and {LOG_MAX}"
                )));
            }
            Call::ListCommits {
                repo: repo()?,
                rev: got.text("rev")?,
                path: got.text("path")?,
                limit: limit as u32,
            }
        }
        "browse_tree" => Call::BrowseTree {
            repo: repo()?,
            oid: got.text("oid")?,
        },
        "read_file" => Call::ReadFile {
            repo: repo()?,
            oid: got.text("oid")?,
            max_bytes: READ_FILE_MAX,
        },
        "search_code" => Call::SearchCode {
            repo: repo()?,
            pattern: got.text("pattern")?,
            rev: got.text("rev")?,
        },
        "diff" => Call::Diff {
            repo: repo()?,
            from: got.text("from")?,
            to: got.text("to")?,
            paths: got.paths("paths")?,
        },
        "list_issues" => Call::ListIssues { repo: repo()? },
        "get_issue" => Call::GetIssue {
            repo: repo()?,
            number: got.required_number("number")?,
        },
        "list_merge_requests" => {
            let state = got.text("state")?;
            Call::ListMergeRequests {
                repo: repo()?,
                state: if state.is_empty() {
                    "all".to_owned()
                } else {
                    state
                },
            }
        }
        "get_merge_request" => Call::GetMergeRequest {
            repo: repo()?,
            number: got.required_number("number")?,
        },
        "comment_on_issue" => Call::CommentOnIssue {
            repo: repo()?,
            number: got.required_number("number")?,
            body: got.text("body")?,
        },
        // Unreachable while TOOLS and this match agree; the test
        // `every_listed_tool_parses` holds them together.
        other => return Err(Refused::Unknown(said_name(other))),
    };
    Ok(call)
}

struct Got<'a> {
    tool: &'static Tool,
    args: &'a Map<String, Value>,
}

fn arg(tool: &'static Tool, name: &str) -> &'static Arg {
    tool.args
        .iter()
        .find(|a| a.name == name)
        .expect("an argument the tool declares")
}

fn got_str(tool: &'static Tool, args: &Map<String, Value>, name: &str) -> Result<String, Refused> {
    let spec = arg(tool, name);
    match args.get(name) {
        None | Some(Value::Null) if spec.required => {
            Err(Refused::Argument(format!("{} needs {name}", tool.name)))
        }
        None | Some(Value::Null) => Ok(String::new()),
        Some(Value::String(s)) => checked(tool, spec, s).map(str::to_owned),
        Some(_) => Err(Refused::Argument(format!("{name} must be a string"))),
    }
}

impl Got<'_> {
    fn text(&self, name: &str) -> Result<String, Refused> {
        got_str(self.tool, self.args, name)
    }

    fn number(&self, name: &str) -> Result<Option<u64>, Refused> {
        match self.args.get(name) {
            None | Some(Value::Null) => Ok(None),
            Some(Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| {
                Refused::Argument(format!("{name} must be a positive whole number"))
            }),
            Some(_) => Err(Refused::Argument(format!("{name} must be a number"))),
        }
    }

    fn required_number(&self, name: &str) -> Result<u64, Refused> {
        match self.number(name)? {
            Some(n) if n > 0 => Ok(n),
            Some(_) => Err(Refused::Argument(format!("{name} must be at least 1"))),
            None => Err(Refused::Argument(format!(
                "{} needs {name}",
                self.tool.name
            ))),
        }
    }

    fn paths(&self, name: &str) -> Result<Vec<String>, Refused> {
        let spec = arg(self.tool, name);
        match self.args.get(name) {
            None | Some(Value::Null) => Ok(Vec::new()),
            Some(Value::Array(items)) if items.len() <= 32 => items
                .iter()
                .map(|v| match v {
                    Value::String(s) => checked(
                        self.tool,
                        &Arg {
                            kind: Kind::Path,
                            ..*spec
                        },
                        s,
                    )
                    .map(str::to_owned),
                    _ => Err(Refused::Argument(format!(
                        "{name} must be a list of strings"
                    ))),
                })
                .collect(),
            Some(_) => Err(Refused::Argument(format!(
                "{name} must be a list of at most 32 paths"
            ))),
        }
    }
}

/// **No address, anywhere.** A value that carries a URL scheme, or that
/// names a host the way an ssh remote does, is refused before its kind is
/// even looked at: nothing this connector does takes an address.
pub fn no_address(value: &str) -> bool {
    let lower = value.to_ascii_lowercase();
    !(lower.contains("://")
        || lower.starts_with("git@")
        || lower.contains("\\\\")
        || lower.starts_with("//"))
}

fn checked<'v>(tool: &Tool, spec: &Arg, value: &'v str) -> Result<&'v str, Refused> {
    let name = spec.name;
    if !no_address(value) {
        return Err(Refused::Argument(format!("{name} looks like an address, and {} takes none: this connector reaches only this Gunnar's own repositories", tool.name)));
    }
    if value
        .chars()
        .any(|c| c.is_control() && c != '\n' && c != '\t')
    {
        return Err(Refused::Argument(format!(
            "{name} carries a control character"
        )));
    }
    let ok = match spec.kind {
        Kind::Name => {
            !value.is_empty()
                && value.len() <= 100
                && value != "."
                && value != ".."
                && value
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
        }
        Kind::Oid => {
            (value.len() == 40 || value.len() == 64) && value.chars().all(|c| c.is_ascii_hexdigit())
        }
        Kind::Rev => {
            value.len() <= 255
                && value
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-'))
                && !value.contains("..")
                && !value.starts_with('-')
        }
        Kind::Path | Kind::Paths => {
            value.len() <= 4096
                && !value.starts_with('/')
                && !value.split('/').any(|s| s == ".." || s == ".")
        }
        Kind::Text if name == "pattern" => !value.is_empty() && value.len() <= PATTERN_MAX,
        Kind::Text => value.len() <= TEXT_MAX,
        Kind::OneOf(words) => words.contains(&value),
        Kind::Number => false,
    };
    if ok {
        Ok(value)
    } else {
        Err(Refused::Argument(format!(
            "{name} is not {}",
            expected(spec.kind)
        )))
    }
}

fn expected(kind: Kind) -> String {
    match kind {
        Kind::Name => "a plain name (letters, digits, `.`, `_`, `-`)".to_owned(),
        Kind::Oid => "a full hex object id".to_owned(),
        Kind::Rev => "a ref name or a hex oid".to_owned(),
        Kind::Path | Kind::Paths => "a relative path inside the repository".to_owned(),
        Kind::Text => "text of a sensible length".to_owned(),
        Kind::Number => "a number".to_owned(),
        Kind::OneOf(words) => format!(
            "one of {}",
            words
                .iter()
                .map(|w| format!("{w:?}"))
                .collect::<Vec<_>>()
                .join(", ")
        ),
    }
}

/// The JSON Schema of one argument.
fn schema_of(arg: &Arg) -> Value {
    match arg.kind {
        Kind::Number => json!({ "type": "integer", "minimum": 1, "description": arg.about }),
        Kind::Paths => {
            json!({ "type": "array", "items": { "type": "string" }, "maxItems": 32, "description": arg.about })
        }
        Kind::OneOf(words) => json!({ "type": "string", "enum": words, "description": arg.about }),
        Kind::Oid => {
            json!({ "type": "string", "pattern": "^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$", "description": arg.about })
        }
        Kind::Name => {
            json!({ "type": "string", "pattern": "^[A-Za-z0-9._-]{1,100}$", "description": arg.about })
        }
        _ => json!({ "type": "string", "description": arg.about }),
    }
}

/// `tools/list`'s answer for `scope`: the read tools, plus the write tools
/// only under the write grant.
pub fn listed(scope: Scope) -> Value {
    let tools: Vec<Value> = TOOLS
        .iter()
        .filter(|t| scope.allows(t))
        .map(|t| {
            let mut properties = Map::new();
            for a in t.args {
                properties.insert(a.name.to_owned(), schema_of(a));
            }
            let required: Vec<&str> = t
                .args
                .iter()
                .filter(|a| a.required)
                .map(|a| a.name)
                .collect();
            json!({
                "name": t.name,
                "title": t.title,
                "description": t.about,
                "inputSchema": {
                    "type": "object",
                    "properties": properties,
                    "required": required,
                    "additionalProperties": false,
                },
                "annotations": {
                    "title": t.title,
                    "readOnlyHint": !t.write,
                    "destructiveHint": false,
                    "idempotentHint": !t.write,
                    "openWorldHint": false,
                },
            })
        })
        .collect();
    json!({ "tools": tools })
}

// ── answers ────────────────────────────────────────────────────────────────

/// One repository row.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
    /// `namespace/name`.
    pub path: String,
    /// `public`, `internal` or `private`.
    pub visibility: String,
    /// The default branch.
    pub default_branch: String,
    /// The description.
    pub description: String,
}

/// One ref.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ref {
    /// The full ref name.
    pub name: String,
    /// The oid it points at (peeled when it is an annotated tag).
    pub oid: String,
}

/// One commit. The author's NAME only: never an e-mail address.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Commit {
    /// Its oid.
    pub oid: String,
    /// Its tree's oid.
    pub tree: String,
    /// Its parents' oids.
    pub parents: Vec<String>,
    /// The author's name.
    pub author: String,
    /// The author time, seconds since the epoch.
    pub when_unix: i64,
    /// The message.
    pub message: String,
}

/// One tree entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
    /// The git mode (`100644`, `40000`, …).
    pub mode: String,
    /// The name.
    pub name: String,
    /// The oid.
    pub oid: String,
}

/// One search match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
    /// The file.
    pub path: String,
    /// The 1-based line number.
    pub line: u32,
    /// The line.
    pub text: String,
}

/// One changed file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
    /// `added`, `modified`, `deleted`, `renamed`, `copied`, `type-changed`.
    pub kind: String,
    /// The path (new side).
    pub path: String,
    /// The old path, for a rename or copy.
    pub old_path: String,
    /// The old blob oid.
    pub old_oid: String,
    /// The new blob oid.
    pub new_oid: String,
    /// Lines added.
    pub insertions: u64,
    /// Lines removed.
    pub deletions: u64,
    /// True for a binary file.
    pub binary: bool,
}

/// One issue or merge request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
    /// Its number.
    pub number: u64,
    /// `open`, `closed`, `merged`.
    pub state: String,
    /// Its title.
    pub title: String,
    /// Who opened it (a principal name).
    pub author: String,
    /// Its text.
    pub body: String,
    /// Further facts (`source: refs/heads/x`, `comments: 3`).
    pub facts: Vec<(String, String)>,
}

/// What a backend answers for one [`Call`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Answer {
    /// `list_repositories`.
    Repos(Vec<Repo>),
    /// `list_refs`.
    Refs(Vec<Ref>),
    /// `list_commits`.
    Commits(Vec<Commit>),
    /// `browse_tree`.
    Tree(Vec<Entry>),
    /// `read_file`: the bytes, the whole size, and whether it was cut.
    File {
        oid: String,
        size: u64,
        data: Vec<u8>,
        truncated: bool,
    },
    /// `search_code`.
    Matches(Vec<Match>),
    /// `diff`.
    Changes(Vec<Change>),
    /// `list_issues`, `list_merge_requests`.
    Items(Vec<Item>),
    /// `get_issue`, `get_merge_request`.
    Item(Item),
    /// A write tool's sentence.
    Done(String),
}

fn kind_of(mode: &str) -> &'static str {
    match mode {
        "40000" | "040000" => "dir",
        "160000" => "submodule",
        "120000" => "symlink",
        _ => "file",
    }
}

/// **The answer as the text the agent reads**, with every e-mail address
/// taken out ([`redact_emails`]).
pub fn render(answer: &Answer) -> String {
    let mut out = String::new();
    use std::fmt::Write as _;
    match answer {
        Answer::Repos(rows) if rows.is_empty() => {
            out.push_str("No repositories you can see match.\n")
        }
        Answer::Repos(rows) => {
            let _ = writeln!(out, "{} repositories:", rows.len());
            for r in rows {
                let _ = write!(
                    out,
                    "- {} ({}, default branch {})",
                    r.path,
                    r.visibility,
                    if r.default_branch.is_empty() {
                        "none"
                    } else {
                        &r.default_branch
                    }
                );
                if !r.description.is_empty() {
                    let _ = write!(out, ": {}", r.description);
                }
                out.push('\n');
            }
        }
        Answer::Refs(rows) if rows.is_empty() => out.push_str("No refs.\n"),
        Answer::Refs(rows) => {
            for r in rows {
                let _ = writeln!(out, "{} {}", r.oid, r.name);
            }
        }
        Answer::Commits(rows) if rows.is_empty() => out.push_str("No commits.\n"),
        Answer::Commits(rows) => {
            for c in rows {
                let _ = writeln!(out, "commit {}\ntree {}", c.oid, c.tree);
                for p in &c.parents {
                    let _ = writeln!(out, "parent {p}");
                }
                let _ = writeln!(out, "author {} at {}\n", c.author, c.when_unix);
                for line in c.message.trim_end().lines() {
                    let _ = writeln!(out, "    {line}");
                }
                out.push('\n');
            }
        }
        Answer::Tree(rows) if rows.is_empty() => out.push_str("An empty tree.\n"),
        Answer::Tree(rows) => {
            for e in rows {
                let _ = writeln!(out, "{} {} {}\t{}", e.mode, kind_of(&e.mode), e.oid, e.name);
            }
        }
        Answer::File {
            oid,
            size,
            data,
            truncated,
        } => {
            let head = &data[..data.len().min(8000)];
            if head.contains(&0) {
                let _ = writeln!(
                    out,
                    "Blob {oid} is a binary file of {size} bytes; it is not shown as text."
                );
            } else {
                out.push_str(&String::from_utf8_lossy(data));
                if *truncated {
                    let _ = write!(
                        out,
                        "\n[cut: this is the first {} of {size} bytes]\n",
                        data.len()
                    );
                }
            }
        }
        Answer::Matches(rows) if rows.is_empty() => out.push_str("No matches.\n"),
        Answer::Matches(rows) => {
            for m in rows {
                let _ = writeln!(out, "{}:{}: {}", m.path, m.line, m.text);
            }
        }
        Answer::Changes(rows) if rows.is_empty() => out.push_str("No files differ.\n"),
        Answer::Changes(rows) => {
            for c in rows {
                let path = if c.old_path.is_empty() || c.old_path == c.path {
                    c.path.clone()
                } else {
                    format!("{} -> {}", c.old_path, c.path)
                };
                let lines = if c.binary {
                    "binary".to_owned()
                } else {
                    format!("+{} -{}", c.insertions, c.deletions)
                };
                let _ = writeln!(
                    out,
                    "{} {path} ({lines}) old {} new {}",
                    c.kind,
                    if c.old_oid.is_empty() {
                        "-"
                    } else {
                        &c.old_oid
                    },
                    if c.new_oid.is_empty() {
                        "-"
                    } else {
                        &c.new_oid
                    }
                );
            }
        }
        Answer::Items(rows) if rows.is_empty() => out.push_str("None.\n"),
        Answer::Items(rows) => {
            for i in rows {
                let _ = write!(
                    out,
                    "#{} [{}] {} (by {})",
                    i.number, i.state, i.title, i.author
                );
                for (k, v) in &i.facts {
                    let _ = write!(out, "; {k}: {v}");
                }
                out.push('\n');
            }
        }
        Answer::Item(i) => {
            let _ = writeln!(
                out,
                "#{} [{}] {}\nby {}",
                i.number, i.state, i.title, i.author
            );
            for (k, v) in &i.facts {
                let _ = writeln!(out, "{k}: {v}");
            }
            if !i.body.is_empty() {
                let _ = writeln!(out, "\n{}", i.body.trim_end());
            }
        }
        Answer::Done(sentence) => {
            out.push_str(sentence);
            out.push('\n');
        }
    }
    redact_emails(&out)
}

/// What an e-mail address is replaced with.
pub const EMAIL_HIDDEN: &str = "[e-mail hidden]";

/// **Every e-mail address in `text`, replaced** with [`EMAIL_HIDDEN`]. The
/// owner's rule: no e-mail address in a log or an answer. Applied to every
/// answer, file contents included.
pub fn redact_emails(text: &str) -> String {
    let bytes = text.as_bytes();
    let local = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'%' | b'+' | b'-');
    let domain = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-');
    let mut out = String::with_capacity(text.len());
    let mut copied = 0;
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] != b'@' {
            i += 1;
            continue;
        }
        let mut start = i;
        while start > copied && local(bytes[start - 1]) {
            start -= 1;
        }
        let mut end = i + 1;
        while end < bytes.len() && domain(bytes[end]) {
            end += 1;
        }
        // Trailing dots and dashes are punctuation, not the domain.
        while end > i + 1 && matches!(bytes[end - 1], b'.' | b'-') {
            end -= 1;
        }
        let dom = &text[i + 1..end];
        let tld = dom.rsplit('.').next().unwrap_or("");
        let is_email = start < i
            && dom.contains('.')
            && tld.len() >= 2
            && tld.bytes().all(|b| b.is_ascii_alphabetic())
            && !dom.starts_with('.');
        if is_email {
            out.push_str(&text[copied..start]);
            out.push_str(EMAIL_HIDDEN);
            copied = end;
            i = end;
        } else {
            i += 1;
        }
    }
    out.push_str(&text[copied..]);
    out
}

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

    #[test]
    fn every_listed_tool_parses_and_the_list_is_the_fixed_one() {
        let names: Vec<&str> = TOOLS.iter().map(|t| t.name).collect();
        assert_eq!(
            names,
            [
                "list_repositories",
                "list_refs",
                "list_commits",
                "browse_tree",
                "read_file",
                "search_code",
                "diff",
                "list_issues",
                "get_issue",
                "list_merge_requests",
                "get_merge_request",
                "comment_on_issue"
            ]
        );
        let oid = "a".repeat(40);
        for t in TOOLS {
            let mut args = Map::new();
            for a in t.args.iter().filter(|a| a.required) {
                let v = match a.kind {
                    Kind::Number => json!(1),
                    Kind::Oid => json!(oid),
                    Kind::Rev => json!("main"),
                    Kind::Paths => json!(["src"]),
                    Kind::OneOf(w) => json!(w[0]),
                    _ => json!("x"),
                };
                args.insert(a.name.to_owned(), v);
            }
            parse(t.name, Some(&Value::Object(args)), Scope::ReadWrite)
                .unwrap_or_else(|e| panic!("{}: {e:?}", t.name));
        }
    }

    #[test]
    fn an_unknown_tool_is_refused_by_name() {
        let refused = parse(
            "fetch_url",
            Some(&json!({ "url": "https://example.invalid" })),
            Scope::ReadWrite,
        )
        .unwrap_err();
        assert_eq!(refused, Refused::Unknown("fetch_url".to_owned()));
        assert!(
            refused.words().starts_with("Unknown tool: fetch_url."),
            "{}",
            refused.words()
        );
        for name in [
            "shell",
            "exec",
            "proxy",
            "http_get",
            "run",
            "",
            "list_repositories ",
        ] {
            assert!(
                matches!(
                    parse(name, None, Scope::ReadWrite),
                    Err(Refused::Unknown(_))
                ),
                "{name:?}"
            );
        }
    }

    #[test]
    fn a_write_tool_needs_the_write_grant_and_is_not_even_listed_without_it() {
        let args = json!({ "namespace": "kim", "repo": "secret", "number": 1, "body": "hi" });
        assert_eq!(
            parse("comment_on_issue", Some(&args), Scope::Read).unwrap_err(),
            Refused::NeedsWrite("comment_on_issue")
        );
        assert!(parse("comment_on_issue", Some(&args), Scope::ReadWrite).is_ok());
        let read = listed(Scope::Read).to_string();
        assert!(!read.contains("comment_on_issue"), "{read}");
        assert!(listed(Scope::ReadWrite)
            .to_string()
            .contains("comment_on_issue"));
    }

    #[test]
    fn no_tool_takes_an_address() {
        // The declared arguments: none is named for an address.
        for t in TOOLS {
            for a in t.args {
                for word in [
                    "url", "uri", "host", "address", "endpoint", "remote", "command", "cmd",
                    "shell", "server", "port",
                ] {
                    assert!(
                        !a.name.contains(word),
                        "{}.{} looks like it takes an address",
                        t.name,
                        a.name
                    );
                }
            }
        }
        // An undeclared argument is refused, an address in a declared one too.
        let extra = parse(
            "list_repositories",
            Some(&json!({ "url": "https://example.invalid" })),
            Scope::Read,
        )
        .unwrap_err();
        assert!(
            matches!(extra, Refused::Argument(ref w) if w.contains("no argument named \"url\"")),
            "{extra:?}"
        );
        for value in [
            "https://example.invalid/x",
            "ssh://git@example.invalid/x",
            "git@example.invalid:x",
            "//example.invalid/x",
            "file:///etc/passwd",
        ] {
            let r = parse(
                "list_repositories",
                Some(&json!({ "query": value })),
                Scope::Read,
            )
            .unwrap_err();
            assert!(
                matches!(r, Refused::Argument(ref w) if w.contains("address")),
                "{value}: {r:?}"
            );
            let r = parse(
                "search_code",
                Some(&json!({ "namespace": "a", "repo": "b", "pattern": value })),
                Scope::Read,
            )
            .unwrap_err();
            assert!(matches!(r, Refused::Argument(_)), "{value}: {r:?}");
        }
    }

    #[test]
    fn arguments_are_checked_by_kind() {
        let bad = |args: Value| parse("browse_tree", Some(&args), Scope::Read).unwrap_err();
        assert!(matches!(
            bad(json!({ "namespace": "a", "repo": "b", "oid": "main" })),
            Refused::Argument(_)
        ));
        assert!(matches!(
            bad(json!({ "namespace": "../a", "repo": "b", "oid": "a".repeat(40) })),
            Refused::Argument(_)
        ));
        assert!(
            matches!(bad(json!({ "namespace": "a", "repo": "b" })), Refused::Argument(ref w) if w.contains("needs oid"))
        );
        let path = parse(
            "list_commits",
            Some(&json!({ "namespace": "a", "repo": "b", "path": "../etc" })),
            Scope::Read,
        )
        .unwrap_err();
        assert!(matches!(path, Refused::Argument(_)));
        let limit = parse(
            "list_commits",
            Some(&json!({ "namespace": "a", "repo": "b", "limit": 500 })),
            Scope::Read,
        )
        .unwrap_err();
        assert!(matches!(limit, Refused::Argument(_)));
        assert_eq!(
            parse(
                "list_commits",
                Some(&json!({ "namespace": "a", "repo": "b" })),
                Scope::Read
            )
            .unwrap(),
            Call::ListCommits {
                repo: RepoName {
                    namespace: "a".into(),
                    name: "b".into()
                },
                rev: String::new(),
                path: String::new(),
                limit: 20
            }
        );
    }

    #[test]
    fn every_schema_forbids_extra_properties() {
        let listed = listed(Scope::ReadWrite);
        for t in listed["tools"].as_array().unwrap() {
            assert_eq!(
                t["inputSchema"]["additionalProperties"],
                json!(false),
                "{t}"
            );
            assert_eq!(t["inputSchema"]["type"], "object");
        }
    }

    #[test]
    fn e_mail_addresses_never_reach_an_answer() {
        assert_eq!(
            redact_emails("Signed-off-by: Kim <kim@mock.invalid>"),
            format!("Signed-off-by: Kim <{EMAIL_HIDDEN}>")
        );
        assert_eq!(
            redact_emails("a.b+c@sub.mock.invalid."),
            format!("{EMAIL_HIDDEN}.")
        );
        assert_eq!(
            redact_emails("git@host:repo and @mention and x@y"),
            "git@host:repo and @mention and x@y"
        );
        assert_eq!(redact_emails("no address here"), "no address here");
        let answer = Answer::Commits(vec![Commit {
            oid: "1".repeat(40),
            tree: "2".repeat(40),
            parents: vec![],
            author: "Kim".into(),
            when_unix: 1,
            message: "fix\n\nCo-authored-by: Eve <eve@mock.invalid>\n".into(),
        }]);
        let text = render(&answer);
        assert!(!text.contains("@mock.invalid"), "{text}");
        assert!(text.contains(EMAIL_HIDDEN));
    }

    #[test]
    fn a_binary_file_is_described_and_a_cut_one_says_so() {
        let bin = render(&Answer::File {
            oid: "o".into(),
            size: 3,
            data: vec![0, 1, 2],
            truncated: false,
        });
        assert!(bin.contains("binary file of 3 bytes"), "{bin}");
        let cut = render(&Answer::File {
            oid: "o".into(),
            size: 10,
            data: b"hello".to_vec(),
            truncated: true,
        });
        assert!(
            cut.starts_with("hello") && cut.contains("first 5 of 10 bytes"),
            "{cut}"
        );
    }
}