Skip to main content

ao_core/config/
project.rs

1//! Per-project configuration: `ProjectConfig`, `detect_git_repo`,
2//! `generate_config`, and related helpers.
3
4use super::{
5    agent::{default_orchestrator_rules, default_permissions, AgentConfig},
6    power::{DefaultsConfig, PluginConfig, RoleAgentConfig},
7    reactions::{default_reactions, default_routing},
8};
9use crate::{
10    error::{AoError, Result},
11    parity_session_strategy::{OpencodeIssueSessionStrategy, OrchestratorSessionStrategy},
12    reactions::ReactionConfig,
13};
14use serde::{Deserialize, Serialize};
15use std::{collections::HashMap, path::Path};
16
17pub(super) fn default_branch_name() -> String {
18    "main".into()
19}
20
21pub(super) fn default_port() -> u16 {
22    3000
23}
24pub(super) fn default_ready_threshold_ms() -> u64 {
25    300_000
26}
27pub(super) fn default_poll_interval_secs() -> u64 {
28    10
29}
30
31/// Per-project configuration.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ProjectConfig {
34    /// Friendly display name (TS: `name`).
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub name: Option<String>,
37    /// GitHub-style `owner/repo`.
38    pub repo: String,
39    /// Absolute path on disk.
40    pub path: String,
41    /// Default branch to use as worktree base.
42    #[serde(
43        default = "default_branch_name",
44        alias = "default-branch",
45        alias = "defaultBranch",
46        rename = "default_branch"
47    )]
48    pub default_branch: String,
49    /// Session prefix (TS: `sessionPrefix`).
50    #[serde(
51        default,
52        skip_serializing_if = "Option::is_none",
53        rename = "sessionPrefix",
54        alias = "session_prefix"
55    )]
56    pub session_prefix: Option<String>,
57    /// Optional per-project override for branch namespace/prefix. See
58    /// `defaults.branch_namespace`.
59    #[serde(
60        default,
61        skip_serializing_if = "Option::is_none",
62        rename = "branch_namespace",
63        alias = "branchNamespace",
64        alias = "branch-namespace"
65    )]
66    pub branch_namespace: Option<String>,
67    /// Per-project plugin overrides (TS: `runtime`, `agent`, `workspace`).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub runtime: Option<String>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub agent: Option<String>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub workspace: Option<String>,
74    /// Issue tracker plugin for `spawn --issue` ("github", "linear", ...).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub tracker: Option<PluginConfig>,
77    /// SCM config (TS: `scm`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub scm: Option<PluginConfig>,
80    /// Files to symlink into workspaces (TS: `symlinks`).
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub symlinks: Vec<String>,
83    /// Commands to run after workspace creation (TS: `postCreate`).
84    #[serde(
85        default,
86        skip_serializing_if = "Vec::is_empty",
87        rename = "postCreate",
88        alias = "post_create"
89    )]
90    pub post_create: Vec<String>,
91    /// Agent-specific overrides.
92    #[serde(
93        default,
94        skip_serializing_if = "Option::is_none",
95        alias = "agent-config",
96        rename = "agent_config"
97    )]
98    pub agent_config: Option<AgentConfig>,
99
100    /// Role overrides for the orchestrator agent (TS: `projects.<id>.orchestrator`).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub orchestrator: Option<RoleAgentConfig>,
103
104    /// Role overrides for worker agents (TS: `projects.<id>.worker`).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub worker: Option<RoleAgentConfig>,
107
108    /// Per-project reaction overrides (TS: `projects.*.reactions`).
109    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110    pub reactions: HashMap<String, ReactionConfig>,
111
112    /// Inline rules/instructions passed to every agent prompt (TS: `agentRules`).
113    #[serde(
114        default,
115        skip_serializing_if = "Option::is_none",
116        rename = "agent_rules",
117        alias = "agentRules",
118        alias = "agent-rules"
119    )]
120    pub agent_rules: Option<String>,
121
122    /// Path to a file containing agent rules, relative to project path (TS: `agentRulesFile`).
123    #[serde(
124        default,
125        skip_serializing_if = "Option::is_none",
126        rename = "agent_rules_file",
127        alias = "agentRulesFile",
128        alias = "agent-rules-file"
129    )]
130    pub agent_rules_file: Option<String>,
131
132    /// System rules for the orchestrator session (TS: `orchestratorRules`).
133    #[serde(
134        default,
135        skip_serializing_if = "Option::is_none",
136        rename = "orchestrator_rules",
137        alias = "orchestratorRules",
138        alias = "orchestrator-rules"
139    )]
140    pub orchestrator_rules: Option<String>,
141
142    /// Strategy for handling existing orchestrator sessions (TS: `orchestratorSessionStrategy`).
143    #[serde(
144        default,
145        skip_serializing_if = "Option::is_none",
146        rename = "orchestrator_session_strategy",
147        alias = "orchestratorSessionStrategy",
148        alias = "orchestrator-session-strategy"
149    )]
150    pub orchestrator_session_strategy: Option<OrchestratorSessionStrategy>,
151
152    /// Strategy for handling existing opencode issue sessions (TS: `opencodeIssueSessionStrategy`).
153    #[serde(
154        default,
155        skip_serializing_if = "Option::is_none",
156        rename = "opencode_issue_session_strategy",
157        alias = "opencodeIssueSessionStrategy",
158        alias = "opencode-issue-session-strategy"
159    )]
160    pub opencode_issue_session_strategy: Option<OpencodeIssueSessionStrategy>,
161}
162
163/// Auto-detect git repo info from a working directory.
164///
165/// Returns `(owner_repo, repo_name, default_branch)` by shelling out to
166/// `git remote get-url origin` and `git rev-parse --abbrev-ref HEAD`.
167pub fn detect_git_repo(cwd: &Path) -> Result<(String, String, String)> {
168    // Parse origin URL → owner/repo
169    let url_output = std::process::Command::new("git")
170        .args(["remote", "get-url", "origin"])
171        .current_dir(cwd)
172        .output()
173        .map_err(AoError::Io)?;
174
175    if !url_output.status.success() {
176        return Err(AoError::Other(
177            "no git remote 'origin' found — run from inside a git repo".into(),
178        ));
179    }
180
181    let url = String::from_utf8_lossy(&url_output.stdout)
182        .trim()
183        .to_string();
184    let owner_repo = parse_owner_repo(&url).ok_or_else(|| {
185        AoError::Other(format!("could not parse owner/repo from remote URL: {url}"))
186    })?;
187    let repo_name = owner_repo
188        .rsplit('/')
189        .next()
190        .unwrap_or(&owner_repo)
191        .to_string();
192
193    // Detect default branch
194    let branch_output = std::process::Command::new("git")
195        .args(["rev-parse", "--abbrev-ref", "HEAD"])
196        .current_dir(cwd)
197        .output()
198        .map_err(AoError::Io)?;
199
200    let default_branch = if branch_output.status.success() {
201        String::from_utf8_lossy(&branch_output.stdout)
202            .trim()
203            .to_string()
204    } else {
205        "main".to_string()
206    };
207
208    Ok((owner_repo, repo_name, default_branch))
209}
210
211/// Parse `owner/repo` from a git remote URL.
212///
213/// Supports HTTPS (`https://github.com/owner/repo.git`) and
214/// SSH (`git@github.com:owner/repo.git`).
215fn parse_owner_repo(url: &str) -> Option<String> {
216    let s = url.trim().trim_end_matches(".git");
217    if let Some(rest) = s.strip_prefix("https://") {
218        // https://github.com/owner/repo
219        let parts: Vec<&str> = rest.splitn(2, '/').collect();
220        if parts.len() == 2 {
221            return Some(parts[1].to_string());
222        }
223    }
224    if let Some(rest) = s.strip_prefix("git@") {
225        // git@github.com:owner/repo
226        if let Some(path) = rest.split(':').nth(1) {
227            return Some(path.to_string());
228        }
229    }
230    None
231}
232
233/// Build a complete config for a detected project.
234pub fn generate_config(cwd: &Path) -> Result<super::AoConfig> {
235    let (owner_repo, repo_name, default_branch) = detect_git_repo(cwd)?;
236    let abs_path = std::fs::canonicalize(cwd)?;
237
238    let mut projects = HashMap::new();
239    projects.insert(
240        repo_name,
241        ProjectConfig {
242            name: None,
243            repo: owner_repo,
244            path: abs_path.to_string_lossy().to_string(),
245            default_branch,
246            session_prefix: None,
247            branch_namespace: None,
248            runtime: None,
249            agent: None,
250            workspace: None,
251            tracker: None,
252            scm: None,
253            symlinks: vec![],
254            post_create: vec![],
255            agent_config: Some(AgentConfig::default()),
256            orchestrator: None,
257            worker: None,
258            reactions: HashMap::new(),
259            agent_rules: None,
260            agent_rules_file: None,
261            orchestrator_rules: None,
262            orchestrator_session_strategy: None,
263            opencode_issue_session_strategy: None,
264        },
265    );
266
267    Ok(super::AoConfig {
268        port: default_port(),
269        ready_threshold_ms: default_ready_threshold_ms(),
270        poll_interval: default_poll_interval_secs(),
271        terminal_port: None,
272        direct_terminal_port: None,
273        power: None,
274        defaults: Some(DefaultsConfig {
275            orchestrator: Some(RoleAgentConfig {
276                agent: Some("cursor".into()),
277                agent_config: Some(AgentConfig {
278                    permissions: default_permissions(),
279                    rules: None,
280                    rules_file: None,
281                    model: None,
282                    orchestrator_model: None,
283                    opencode_session_id: None,
284                }),
285            }),
286            worker: Some(RoleAgentConfig {
287                agent: Some("cursor".into()),
288                agent_config: None,
289            }),
290            orchestrator_rules: Some(default_orchestrator_rules().to_string()),
291            ..DefaultsConfig::default()
292        }),
293        projects,
294        reactions: default_reactions(),
295        notification_routing: default_routing(),
296        notifiers: HashMap::new(),
297        plugins: vec![],
298    })
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::config::AoConfig;
305
306    #[test]
307    fn parse_owner_repo_https() {
308        assert_eq!(
309            parse_owner_repo("https://github.com/owner/repo.git"),
310            Some("owner/repo".into())
311        );
312        assert_eq!(
313            parse_owner_repo("https://github.com/owner/repo"),
314            Some("owner/repo".into())
315        );
316    }
317
318    #[test]
319    fn parse_owner_repo_ssh() {
320        assert_eq!(
321            parse_owner_repo("git@github.com:owner/repo.git"),
322            Some("owner/repo".into())
323        );
324        assert_eq!(
325            parse_owner_repo("git@github.com:owner/repo"),
326            Some("owner/repo".into())
327        );
328    }
329
330    #[test]
331    fn project_config_roundtrip() {
332        let pc = ProjectConfig {
333            name: None,
334            repo: "owner/repo".into(),
335            path: "/tmp/test".into(),
336            default_branch: "main".into(),
337            session_prefix: None,
338            branch_namespace: None,
339            runtime: None,
340            agent: None,
341            workspace: None,
342            tracker: None,
343            scm: None,
344            symlinks: vec![],
345            post_create: vec![],
346            agent_config: Some(AgentConfig::default()),
347            orchestrator: None,
348            worker: None,
349            reactions: HashMap::new(),
350            agent_rules: None,
351            agent_rules_file: None,
352            orchestrator_rules: None,
353            orchestrator_session_strategy: None,
354            opencode_issue_session_strategy: None,
355        };
356        let yaml = serde_yaml::to_string(&pc).unwrap();
357        let pc2: ProjectConfig = serde_yaml::from_str(&yaml).unwrap();
358        assert_eq!(pc, pc2);
359    }
360
361    #[test]
362    fn project_config_without_agent_config() {
363        let pc = ProjectConfig {
364            name: None,
365            repo: "owner/repo".into(),
366            path: "/tmp/test".into(),
367            default_branch: "develop".into(),
368            session_prefix: None,
369            branch_namespace: None,
370            runtime: None,
371            agent: None,
372            workspace: None,
373            tracker: None,
374            scm: None,
375            symlinks: vec![],
376            post_create: vec![],
377            agent_config: None,
378            orchestrator: None,
379            worker: None,
380            reactions: HashMap::new(),
381            agent_rules: None,
382            agent_rules_file: None,
383            orchestrator_rules: None,
384            orchestrator_session_strategy: None,
385            opencode_issue_session_strategy: None,
386        };
387        let yaml = serde_yaml::to_string(&pc).unwrap();
388        assert!(!yaml.contains("agent_config"));
389        let pc2: ProjectConfig = serde_yaml::from_str(&yaml).unwrap();
390        assert_eq!(pc, pc2);
391    }
392
393    #[test]
394    fn generate_config_includes_orchestrator_fields() {
395        let dir = std::env::temp_dir();
396        let cfg = generate_config(&dir).unwrap_or_else(|_| {
397            // Fallback: build a minimal AoConfig just for YAML shape assertions when the temp dir isn't a git repo.
398            let mut projects = HashMap::new();
399            projects.insert(
400                "demo".into(),
401                ProjectConfig {
402                    name: None,
403                    repo: "org/demo".into(),
404                    path: "/tmp/demo".into(),
405                    default_branch: "main".into(),
406                    session_prefix: None,
407                    branch_namespace: None,
408                    runtime: None,
409                    agent: None,
410                    workspace: None,
411                    tracker: None,
412                    scm: None,
413                    symlinks: vec![],
414                    post_create: vec![],
415                    agent_config: Some(AgentConfig::default()),
416                    orchestrator: Some(RoleAgentConfig {
417                        agent: None,
418                        agent_config: Some(AgentConfig {
419                            permissions: default_permissions(),
420                            rules: None,
421                            rules_file: None,
422                            model: None,
423                            orchestrator_model: None,
424                            opencode_session_id: None,
425                        }),
426                    }),
427                    worker: None,
428                    reactions: HashMap::new(),
429                    agent_rules: None,
430                    agent_rules_file: None,
431                    orchestrator_rules: None,
432                    orchestrator_session_strategy: None,
433                    opencode_issue_session_strategy: None,
434                },
435            );
436            AoConfig {
437                port: default_port(),
438                ready_threshold_ms: default_ready_threshold_ms(),
439                poll_interval: default_poll_interval_secs(),
440                terminal_port: None,
441                direct_terminal_port: None,
442                power: None,
443                defaults: Some(DefaultsConfig {
444                    orchestrator_rules: Some(default_orchestrator_rules().to_string()),
445                    ..DefaultsConfig::default()
446                }),
447                projects,
448                reactions: default_reactions(),
449                notification_routing: default_routing(),
450                notifiers: HashMap::new(),
451                plugins: vec![],
452            }
453        });
454
455        let yaml = serde_yaml::to_string(&cfg).unwrap();
456        assert!(yaml.contains("orchestrator_rules:"));
457        assert!(yaml.contains("orchestrator:"));
458        assert!(yaml.contains("agent_config:"));
459    }
460
461    #[test]
462    fn camel_case_default_branch_loads_correctly() {
463        use std::sync::atomic::{AtomicUsize, Ordering};
464        use std::time::{SystemTime, UNIX_EPOCH};
465
466        static COUNTER: AtomicUsize = AtomicUsize::new(0);
467        let nanos = SystemTime::now()
468            .duration_since(UNIX_EPOCH)
469            .unwrap()
470            .as_nanos();
471        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
472        let path =
473            std::env::temp_dir().join(format!("ao-rs-config-camelcase-branch-{nanos}-{n}.yaml"));
474
475        std::fs::write(
476            &path,
477            r#"
478projects:
479  my-app:
480    repo: org/my-app
481    path: /tmp/my-app
482    defaultBranch: develop
483"#,
484        )
485        .unwrap();
486        let cfg = AoConfig::load_from(&path).unwrap();
487        assert_eq!(
488            cfg.projects["my-app"].default_branch, "develop",
489            "camelCase defaultBranch must be accepted"
490        );
491        let _ = std::fs::remove_file(&path);
492    }
493}