forge_workspace/mcp/agents/
target.rs1use crate::mcp::peers::types::PeerStatus;
4
5pub use forge_primitives::LEAD_LABEL;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct AgentTarget {
16 org: String,
17 project: String,
18 label: String,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum TargetError {
27 Malformed { component: &'static str },
29 UnknownProject { org: String, project: String },
31}
32
33impl AgentTarget {
34 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 pub fn org(&self) -> &str {
70 &self.org
71 }
72
73 pub fn project(&self) -> &str {
75 &self.project
76 }
77
78 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 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 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}