Skip to main content

clawft_types/
company.rs

1//! Company and org-chart types for the Paperclip Patterns integration.
2//!
3//! Models organizational structure: companies, agent roles within an
4//! org chart, and the hierarchical reporting relationships between agents.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// A company or organisation that owns agents.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Company {
12    /// Unique identifier (UUID or slug).
13    pub id: String,
14    /// Human-readable company name.
15    pub name: String,
16    /// Optional description of the company's purpose.
17    #[serde(default)]
18    pub description: String,
19    /// When the company record was created.
20    pub created_at: DateTime<Utc>,
21}
22
23impl Company {
24    /// Create a new company with the current timestamp.
25    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
26        Self {
27            id: id.into(),
28            name: name.into(),
29            description: String::new(),
30            created_at: Utc::now(),
31        }
32    }
33}
34
35/// Role an agent holds within an organisational chart.
36///
37/// This is distinct from [`clawft_kernel::AgentRole`] which describes
38/// the OS-level role (root, supervisor, worker). `OrgRole` models
39/// business-level hierarchy inspired by the Paperclip Patterns.
40#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum OrgRole {
44    /// Chief executive -- top-level strategic agent.
45    Ceo,
46    /// Middle management -- coordinates workers.
47    Manager,
48    /// Executes tasks assigned by managers.
49    Worker,
50    /// A custom organisational role.
51    Custom(String),
52}
53
54impl std::fmt::Display for OrgRole {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            OrgRole::Ceo => write!(f, "ceo"),
58            OrgRole::Manager => write!(f, "manager"),
59            OrgRole::Worker => write!(f, "worker"),
60            OrgRole::Custom(name) => write!(f, "custom({name})"),
61        }
62    }
63}
64
65/// A node in an organisational chart, representing one agent's position.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct OrgNode {
68    /// The agent occupying this position.
69    pub agent_id: String,
70    /// Organisational role.
71    pub role: OrgRole,
72    /// Who this agent reports to (`None` for the CEO / root).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub reports_to: Option<String>,
75    /// Budget allocated to this node, in cents (avoids float precision).
76    #[serde(default)]
77    pub budget_cents: u64,
78    /// High-level goals assigned to this node.
79    #[serde(default)]
80    pub goals: Vec<String>,
81}
82
83/// An organisational chart linking a company to its agent hierarchy.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct OrgChart {
86    /// The company this chart belongs to.
87    pub company_id: String,
88    /// Ordered list of org nodes (first node is typically the CEO).
89    pub nodes: Vec<OrgNode>,
90}
91
92impl OrgChart {
93    /// Create an empty org chart for a company.
94    pub fn new(company_id: impl Into<String>) -> Self {
95        Self {
96            company_id: company_id.into(),
97            nodes: Vec::new(),
98        }
99    }
100
101    /// Return all direct reports of the given agent.
102    pub fn direct_reports(&self, agent_id: &str) -> Vec<&OrgNode> {
103        self.nodes
104            .iter()
105            .filter(|n| n.reports_to.as_deref() == Some(agent_id))
106            .collect()
107    }
108
109    /// Find a node by agent ID.
110    pub fn find_agent(&self, agent_id: &str) -> Option<&OrgNode> {
111        self.nodes.iter().find(|n| n.agent_id == agent_id)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn company_new() {
121        let c = Company::new("acme-1", "Acme Corp");
122        assert_eq!(c.id, "acme-1");
123        assert_eq!(c.name, "Acme Corp");
124        assert!(c.description.is_empty());
125    }
126
127    #[test]
128    fn company_serde_roundtrip() {
129        let c = Company {
130            id: "co-1".into(),
131            name: "Test Inc".into(),
132            description: "A test company".into(),
133            created_at: Utc::now(),
134        };
135        let json = serde_json::to_string(&c).unwrap();
136        let restored: Company = serde_json::from_str(&json).unwrap();
137        assert_eq!(restored.id, "co-1");
138        assert_eq!(restored.name, "Test Inc");
139        assert_eq!(restored.description, "A test company");
140    }
141
142    #[test]
143    fn org_role_serde() {
144        let roles = [
145            (OrgRole::Ceo, "\"ceo\""),
146            (OrgRole::Manager, "\"manager\""),
147            (OrgRole::Worker, "\"worker\""),
148        ];
149        for (role, expected) in &roles {
150            let json = serde_json::to_string(role).unwrap();
151            assert_eq!(&json, expected);
152            let restored: OrgRole = serde_json::from_str(&json).unwrap();
153            assert_eq!(&restored, role);
154        }
155    }
156
157    #[test]
158    fn org_role_custom_serde() {
159        let role = OrgRole::Custom("intern".into());
160        let json = serde_json::to_string(&role).unwrap();
161        let restored: OrgRole = serde_json::from_str(&json).unwrap();
162        assert_eq!(restored, OrgRole::Custom("intern".into()));
163    }
164
165    #[test]
166    fn org_role_display() {
167        assert_eq!(OrgRole::Ceo.to_string(), "ceo");
168        assert_eq!(OrgRole::Manager.to_string(), "manager");
169        assert_eq!(OrgRole::Worker.to_string(), "worker");
170        assert_eq!(OrgRole::Custom("vp".into()).to_string(), "custom(vp)");
171    }
172
173    #[test]
174    fn org_node_serde_roundtrip() {
175        let node = OrgNode {
176            agent_id: "agent-a".into(),
177            role: OrgRole::Manager,
178            reports_to: Some("agent-ceo".into()),
179            budget_cents: 500_000,
180            goals: vec!["ship v2".into(), "reduce churn".into()],
181        };
182        let json = serde_json::to_string(&node).unwrap();
183        let restored: OrgNode = serde_json::from_str(&json).unwrap();
184        assert_eq!(restored.agent_id, "agent-a");
185        assert_eq!(restored.role, OrgRole::Manager);
186        assert_eq!(restored.reports_to.as_deref(), Some("agent-ceo"));
187        assert_eq!(restored.budget_cents, 500_000);
188        assert_eq!(restored.goals.len(), 2);
189    }
190
191    #[test]
192    fn org_node_omits_none_reports_to() {
193        let node = OrgNode {
194            agent_id: "ceo".into(),
195            role: OrgRole::Ceo,
196            reports_to: None,
197            budget_cents: 0,
198            goals: vec![],
199        };
200        let json = serde_json::to_string(&node).unwrap();
201        assert!(!json.contains("reports_to"));
202    }
203
204    #[test]
205    fn org_chart_new_is_empty() {
206        let chart = OrgChart::new("co-1");
207        assert_eq!(chart.company_id, "co-1");
208        assert!(chart.nodes.is_empty());
209    }
210
211    #[test]
212    fn org_chart_direct_reports() {
213        let chart = OrgChart {
214            company_id: "co-1".into(),
215            nodes: vec![
216                OrgNode {
217                    agent_id: "ceo".into(),
218                    role: OrgRole::Ceo,
219                    reports_to: None,
220                    budget_cents: 1_000_000,
221                    goals: vec!["grow".into()],
222                },
223                OrgNode {
224                    agent_id: "mgr-1".into(),
225                    role: OrgRole::Manager,
226                    reports_to: Some("ceo".into()),
227                    budget_cents: 300_000,
228                    goals: vec![],
229                },
230                OrgNode {
231                    agent_id: "mgr-2".into(),
232                    role: OrgRole::Manager,
233                    reports_to: Some("ceo".into()),
234                    budget_cents: 200_000,
235                    goals: vec![],
236                },
237                OrgNode {
238                    agent_id: "wk-1".into(),
239                    role: OrgRole::Worker,
240                    reports_to: Some("mgr-1".into()),
241                    budget_cents: 0,
242                    goals: vec![],
243                },
244            ],
245        };
246        let ceo_reports = chart.direct_reports("ceo");
247        assert_eq!(ceo_reports.len(), 2);
248        assert!(ceo_reports.iter().any(|n| n.agent_id == "mgr-1"));
249        assert!(ceo_reports.iter().any(|n| n.agent_id == "mgr-2"));
250
251        let mgr1_reports = chart.direct_reports("mgr-1");
252        assert_eq!(mgr1_reports.len(), 1);
253        assert_eq!(mgr1_reports[0].agent_id, "wk-1");
254
255        let wk_reports = chart.direct_reports("wk-1");
256        assert!(wk_reports.is_empty());
257    }
258
259    #[test]
260    fn org_chart_find_agent() {
261        let chart = OrgChart {
262            company_id: "co-1".into(),
263            nodes: vec![OrgNode {
264                agent_id: "a1".into(),
265                role: OrgRole::Worker,
266                reports_to: None,
267                budget_cents: 0,
268                goals: vec![],
269            }],
270        };
271        assert!(chart.find_agent("a1").is_some());
272        assert!(chart.find_agent("nonexistent").is_none());
273    }
274
275    #[test]
276    fn org_chart_serde_roundtrip() {
277        let chart = OrgChart {
278            company_id: "co-1".into(),
279            nodes: vec![
280                OrgNode {
281                    agent_id: "ceo".into(),
282                    role: OrgRole::Ceo,
283                    reports_to: None,
284                    budget_cents: 1_000_000,
285                    goals: vec!["profit".into()],
286                },
287                OrgNode {
288                    agent_id: "w1".into(),
289                    role: OrgRole::Worker,
290                    reports_to: Some("ceo".into()),
291                    budget_cents: 0,
292                    goals: vec![],
293                },
294            ],
295        };
296        let json = serde_json::to_string(&chart).unwrap();
297        let restored: OrgChart = serde_json::from_str(&json).unwrap();
298        assert_eq!(restored.company_id, "co-1");
299        assert_eq!(restored.nodes.len(), 2);
300    }
301}