Skip to main content

forge_workspace/mcp/agents/
target.rs

1//! The address an `agents__*` call names.
2
3use crate::mcp::peers::types::PeerStatus;
4
5/// The reserved spelling for a project's own agent - the same one the
6/// worker and cron paths already resolve a slot with.
7pub use forge_primitives::LEAD_LABEL;
8
9/// A seat named the way forge names one: the `(org, project, label)`
10/// slot, with `lead` reserved for a project's own agent.
11///
12/// Only [`AgentTarget::parse`] builds one, so a target that reaches a
13/// tool always names a project configured under the org it claims.
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct AgentTarget {
16    org: String,
17    project: String,
18    label: String,
19}
20
21/// Why a caller-supplied target did not resolve to a seat. The two are
22/// separate variants because they call for different corrections: a
23/// malformed target needs a field filled in, an unknown one needs the
24/// project name checked.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum TargetError {
27    /// A component the caller supplied is empty or whitespace.
28    Malformed { component: &'static str },
29    /// No project by that name is configured under that org.
30    UnknownProject { org: String, project: String },
31}
32
33impl AgentTarget {
34    /// Resolve a caller-supplied target against the projects forge is
35    /// configured with. `known` is the same snapshot `list` returns, so
36    /// anything that resolves here is also something the caller can see.
37    ///
38    /// Components are matched verbatim, never trimmed: a target that
39    /// nearly names a seat has to refuse, since resolving it would send
40    /// the message somewhere the caller did not ask for.
41    pub fn parse(
42        known: &[PeerStatus],
43        org: &str,
44        project: &str,
45        label: Option<&str>,
46    ) -> Result<Self, TargetError> {
47        for (component, value) in [("org", org), ("project", project)] {
48            if value.trim().is_empty() {
49                return Err(TargetError::Malformed { component });
50            }
51        }
52        let label = match label {
53            None => LEAD_LABEL,
54            Some(label) if label.trim().is_empty() => {
55                return Err(TargetError::Malformed { component: "label" });
56            }
57            Some(label) => label,
58        };
59        if !known.iter().any(|p| p.org == org && p.name == project) {
60            return Err(TargetError::UnknownProject {
61                org: org.to_owned(),
62                project: project.to_owned(),
63            });
64        }
65        Ok(Self { org: org.to_owned(), project: project.to_owned(), label: label.to_owned() })
66    }
67
68    /// Org the target's project belongs to.
69    pub fn org(&self) -> &str {
70        &self.org
71    }
72
73    /// Project whose seat the target names.
74    pub fn project(&self) -> &str {
75        &self.project
76    }
77
78    /// Label naming the seat within the project; `lead` for the project
79    /// agent itself.
80    pub fn label(&self) -> &str {
81        &self.label
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::mcp::peers::types::{PeerLiveness, PeerStatus};
89    use std::path::PathBuf;
90
91    fn configured(org: &str, name: &str) -> PeerStatus {
92        PeerStatus {
93            name: name.to_owned(),
94            org: org.to_owned(),
95            path: PathBuf::from(format!("/tmp/{name}")),
96            status: PeerLiveness::Sleeping,
97            spawned_at: None,
98        }
99    }
100
101    /// Two projects whose names do not overlap, so a target that resolves
102    /// by name alone is distinguishable from one that needs its org.
103    fn configured_projects() -> Vec<PeerStatus> {
104        vec![configured("acme", "core"), configured("other", "proj")]
105    }
106
107    #[test]
108    fn a_target_without_a_label_is_the_projects_agent() {
109        let target = AgentTarget::parse(&configured_projects(), "acme", "core", None)
110            .expect("core is configured under acme");
111        assert_eq!(target.label(), LEAD_LABEL);
112    }
113
114    #[test]
115    fn a_project_the_org_does_not_own_is_refused() {
116        // `proj` exists, but under `other`. Matching on the name alone would
117        // send to the wrong seat, which is worse than refusing. The VARIANT
118        // is the assertion, not just a refusal: reporting a malformed
119        // component would tell the caller its filled-in project field is
120        // empty, and hide the roster it needs to correct itself.
121        let projects = configured_projects();
122        assert!(matches!(
123            AgentTarget::parse(&projects, "acme", "proj", Some("w")),
124            Err(TargetError::UnknownProject { .. })
125        ));
126        assert!(matches!(
127            AgentTarget::parse(&projects, "acme", "absent", Some("w")),
128            Err(TargetError::UnknownProject { .. })
129        ));
130    }
131
132    #[test]
133    fn a_target_missing_a_component_is_malformed() {
134        let projects = configured_projects();
135        assert!(matches!(
136            AgentTarget::parse(&projects, "", "core", None),
137            Err(TargetError::Malformed { .. })
138        ));
139        assert!(matches!(
140            AgentTarget::parse(&projects, "acme", "core", Some("")),
141            Err(TargetError::Malformed { .. })
142        ));
143    }
144}