1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ProjectConfig {
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub name: Option<String>,
37 pub repo: String,
39 pub path: String,
41 #[serde(
43 default = "default_branch_name",
44 alias = "default-branch",
45 alias = "defaultBranch",
46 rename = "default_branch"
47 )]
48 pub default_branch: String,
49 #[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 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub tracker: Option<PluginConfig>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub scm: Option<PluginConfig>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub symlinks: Vec<String>,
83 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub orchestrator: Option<RoleAgentConfig>,
103
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub worker: Option<RoleAgentConfig>,
107
108 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
110 pub reactions: HashMap<String, ReactionConfig>,
111
112 #[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 #[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 #[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 #[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 #[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
163pub fn detect_git_repo(cwd: &Path) -> Result<(String, String, String)> {
168 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 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
211fn 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 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 if let Some(path) = rest.split(':').nth(1) {
227 return Some(path.to_string());
228 }
229 }
230 None
231}
232
233pub 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 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}