1use std::path::{Path, PathBuf};
19
20use crate::agents::planning as agent_planning;
21use crate::error::AgentConfigError;
22use crate::integration::{
23 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
24};
25use crate::paths;
26use crate::plan::{InstallPlan, UninstallPlan};
27use crate::scope::{Scope, ScopeKind};
28use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
29use crate::status::StatusReport;
30use crate::util::{
31 file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
32 skills_dir,
33};
34
35#[derive(Debug, Clone, Copy, Default)]
37pub struct QwenAgent {
38 _private: (),
39}
40
41impl QwenAgent {
42 pub const fn new() -> Self {
44 Self { _private: () }
45 }
46
47 fn qwen_home_from_home(home: &Path) -> PathBuf {
48 home.join(".qwen")
49 }
50
51 fn settings_path_from_home(home: &Path) -> PathBuf {
52 Self::qwen_home_from_home(home).join("settings.json")
53 }
54
55 fn memory_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
56 Ok(match scope {
57 Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?).join("QWEN.md"),
58 Scope::Local(p) => p.join("QWEN.md"),
59 })
60 }
61
62 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
63 Ok(match scope {
64 Scope::Global => Self::settings_path_from_home(&paths::home_dir()?),
65 Scope::Local(p) => p.join(".qwen").join("settings.json"),
66 })
67 }
68
69 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
70 Ok(match scope {
71 Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?).join("skills"),
72 Scope::Local(p) => p.join(".qwen").join("skills"),
73 })
74 }
75
76 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
79 Ok(match scope {
80 Scope::Global => Self::qwen_home_from_home(&paths::home_dir()?),
81 Scope::Local(p) => p.join(".qwen"),
82 })
83 }
84}
85
86impl Integration for QwenAgent {
87 fn id(&self) -> &'static str {
88 "qwen"
89 }
90
91 fn display_name(&self) -> &'static str {
92 "Qwen Code"
93 }
94
95 fn supported_scopes(&self) -> &'static [ScopeKind] {
96 &[ScopeKind::Global, ScopeKind::Local]
97 }
98
99 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
100 HookSpec::validate_tag(tag)?;
101 let path = Self::memory_path(scope)?;
102 StatusReport::for_markdown_block_hook(tag, path)
103 }
104
105 fn plan_install(
106 &self,
107 scope: &Scope,
108 spec: &HookSpec,
109 ) -> Result<InstallPlan, AgentConfigError> {
110 agent_planning::markdown_install(
111 Integration::id(self),
112 scope,
113 spec,
114 Self::memory_path(scope),
115 true,
116 )
117 }
118
119 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
120 agent_planning::markdown_uninstall(
121 Integration::id(self),
122 scope,
123 tag,
124 Self::memory_path(scope),
125 )
126 }
127
128 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
129 HookSpec::validate_tag(&spec.tag)?;
130 let rules = spec
131 .rules
132 .as_ref()
133 .ok_or(AgentConfigError::MissingSpecField {
134 id: "qwen",
135 field: "rules",
136 })?;
137 let path = Self::memory_path(scope)?;
138 let mut report = InstallReport::default();
139 scope.ensure_contained(&path)?;
140 file_lock::with_lock(&path, || {
141 let host = fs_atomic::read_to_string_or_empty(&path)?;
142 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
143 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
144 if outcome.no_change {
145 report.already_installed = true;
146 } else if outcome.existed {
147 report.patched.push(outcome.path.clone());
148 } else {
149 report.created.push(outcome.path.clone());
150 }
151 if let Some(b) = outcome.backup {
152 report.backed_up.push(b);
153 }
154 Ok::<(), AgentConfigError>(())
155 })?;
156 Ok(report)
157 }
158
159 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
160 HookSpec::validate_tag(tag)?;
161 let path = Self::memory_path(scope)?;
162 let mut report = UninstallReport::default();
163 scope.ensure_contained(&path)?;
164 file_lock::with_lock(&path, || {
165 let host = fs_atomic::read_to_string_or_empty(&path)?;
166 let (stripped, removed) = md_block::remove(&host, tag);
167
168 if !removed {
169 report.not_installed = true;
170 return Ok(());
171 }
172
173 if stripped.trim().is_empty() {
174 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
175 report.restored.push(path.clone());
176 } else {
177 safe_fs::remove_file(scope, &path)?;
178 report.removed.push(path.clone());
179 }
180 } else {
181 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
182 report.patched.push(path.clone());
183 }
184 Ok::<(), AgentConfigError>(())
185 })?;
186 Ok(report)
187 }
188}
189
190impl McpSurface for QwenAgent {
191 fn id(&self) -> &'static str {
192 "qwen"
193 }
194
195 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
196 &[ScopeKind::Global, ScopeKind::Local]
197 }
198
199 fn mcp_status(
200 &self,
201 scope: &Scope,
202 name: &str,
203 expected_owner: &str,
204 ) -> Result<StatusReport, AgentConfigError> {
205 McpSpec::validate_name(name)?;
206 let cfg = Self::mcp_path(scope)?;
207 let ledger = ownership::mcp_ledger_for(&cfg);
208 let presence = mcp_json_object::config_presence(&cfg, name)?;
209 let recorded = ownership::owner_of(&ledger, name)?;
210 Ok(StatusReport::for_mcp(
211 name,
212 cfg,
213 ledger,
214 presence,
215 expected_owner,
216 recorded,
217 ))
218 }
219
220 fn plan_install_mcp(
221 &self,
222 scope: &Scope,
223 spec: &McpSpec,
224 ) -> Result<InstallPlan, AgentConfigError> {
225 agent_planning::mcp_json_object_install(
226 McpSurface::id(self),
227 scope,
228 spec,
229 Self::mcp_path(scope),
230 )
231 }
232
233 fn plan_uninstall_mcp(
234 &self,
235 scope: &Scope,
236 name: &str,
237 owner_tag: &str,
238 ) -> Result<UninstallPlan, AgentConfigError> {
239 agent_planning::mcp_json_object_uninstall(
240 McpSurface::id(self),
241 scope,
242 name,
243 owner_tag,
244 Self::mcp_path(scope),
245 )
246 }
247
248 fn install_mcp(
249 &self,
250 scope: &Scope,
251 spec: &McpSpec,
252 ) -> Result<InstallReport, AgentConfigError> {
253 spec.validate()?;
254 let cfg = Self::mcp_path(scope)?;
255 spec.validate_local_secret_policy(scope)?;
256 scope.ensure_contained(&cfg)?;
257 let ledger = ownership::mcp_ledger_for(&cfg);
258 mcp_json_object::install(&cfg, &ledger, spec)
259 }
260
261 fn uninstall_mcp(
262 &self,
263 scope: &Scope,
264 name: &str,
265 owner_tag: &str,
266 ) -> Result<UninstallReport, AgentConfigError> {
267 McpSpec::validate_name(name)?;
268 HookSpec::validate_tag(owner_tag)?;
269 let cfg = Self::mcp_path(scope)?;
270 scope.ensure_contained(&cfg)?;
271 let ledger = ownership::mcp_ledger_for(&cfg);
272 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
273 }
274}
275
276impl SkillSurface for QwenAgent {
277 fn id(&self) -> &'static str {
278 "qwen"
279 }
280
281 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
282 &[ScopeKind::Global, ScopeKind::Local]
283 }
284
285 fn skill_status(
286 &self,
287 scope: &Scope,
288 name: &str,
289 expected_owner: &str,
290 ) -> Result<StatusReport, AgentConfigError> {
291 SkillSpec::validate_name(name)?;
292 let root = Self::skills_root(scope)?;
293 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
294 let recorded = ownership::owner_of(&ledger, name)?;
295 Ok(StatusReport::for_skill(
296 name,
297 dir,
298 manifest,
299 ledger,
300 expected_owner,
301 recorded,
302 ))
303 }
304
305 fn plan_install_skill(
306 &self,
307 scope: &Scope,
308 spec: &SkillSpec,
309 ) -> Result<InstallPlan, AgentConfigError> {
310 agent_planning::skill_install(
311 SkillSurface::id(self),
312 scope,
313 spec,
314 Self::skills_root(scope),
315 )
316 }
317
318 fn plan_uninstall_skill(
319 &self,
320 scope: &Scope,
321 name: &str,
322 owner_tag: &str,
323 ) -> Result<UninstallPlan, AgentConfigError> {
324 agent_planning::skill_uninstall(
325 SkillSurface::id(self),
326 scope,
327 name,
328 owner_tag,
329 Self::skills_root(scope),
330 )
331 }
332
333 fn install_skill(
334 &self,
335 scope: &Scope,
336 spec: &SkillSpec,
337 ) -> Result<InstallReport, AgentConfigError> {
338 let root = Self::skills_root(scope)?;
339 scope.ensure_contained(&root)?;
340 skills_dir::install(&root, spec)
341 }
342
343 fn uninstall_skill(
344 &self,
345 scope: &Scope,
346 name: &str,
347 owner_tag: &str,
348 ) -> Result<UninstallReport, AgentConfigError> {
349 let root = Self::skills_root(scope)?;
350 scope.ensure_contained(&root)?;
351 skills_dir::uninstall(&root, name, owner_tag)
352 }
353}
354
355impl QwenAgent {
356 fn inline_layout(
357 &self,
358 scope: &Scope,
359 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
360 Ok(instructions_dir::InlineLayout {
361 config_dir: Self::instruction_config_dir(scope)?,
362 host_file: Self::memory_path(scope)?,
363 })
364 }
365}
366
367impl InstructionSurface for QwenAgent {
368 fn id(&self) -> &'static str {
369 "qwen"
370 }
371
372 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
373 &[ScopeKind::Global, ScopeKind::Local]
374 }
375
376 fn instruction_status(
377 &self,
378 scope: &Scope,
379 name: &str,
380 expected_owner: &str,
381 ) -> Result<StatusReport, AgentConfigError> {
382 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
383 }
384
385 fn plan_install_instruction(
386 &self,
387 scope: &Scope,
388 spec: &InstructionSpec,
389 ) -> Result<InstallPlan, AgentConfigError> {
390 instructions_dir::inline_plan_install(
391 InstructionSurface::id(self),
392 scope,
393 self.inline_layout(scope),
394 spec,
395 )
396 }
397
398 fn plan_uninstall_instruction(
399 &self,
400 scope: &Scope,
401 name: &str,
402 owner_tag: &str,
403 ) -> Result<UninstallPlan, AgentConfigError> {
404 instructions_dir::inline_plan_uninstall(
405 InstructionSurface::id(self),
406 scope,
407 self.inline_layout(scope),
408 name,
409 owner_tag,
410 )
411 }
412
413 fn install_instruction(
414 &self,
415 scope: &Scope,
416 spec: &InstructionSpec,
417 ) -> Result<InstallReport, AgentConfigError> {
418 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
419 }
420
421 fn uninstall_instruction(
422 &self,
423 scope: &Scope,
424 name: &str,
425 owner_tag: &str,
426 ) -> Result<UninstallReport, AgentConfigError> {
427 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use serde_json::{json, Value};
435 use tempfile::tempdir;
436
437 fn rules_spec(tag: &str, body: &str) -> HookSpec {
438 HookSpec::builder(tag)
439 .command_program("noop", [] as [&str; 0])
440 .rules(body)
441 .build()
442 }
443
444 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
445 McpSpec::builder(name)
446 .owner(owner)
447 .stdio("npx", ["-y", "@example/server"])
448 .build()
449 }
450
451 fn skill(name: &str, owner: &str) -> SkillSpec {
452 SkillSpec::builder(name)
453 .owner(owner)
454 .description("Test Qwen skill.")
455 .body("## Goal\nDo it.\n")
456 .build()
457 }
458
459 fn read_json(p: &Path) -> Value {
460 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
461 }
462
463 #[test]
464 fn install_writes_qwen_md_block() {
465 let dir = tempdir().unwrap();
466 let agent = QwenAgent::new();
467 let scope = Scope::Local(dir.path().to_path_buf());
468 agent
469 .install(&scope, &rules_spec("alpha", "Use Qwen rules."))
470 .unwrap();
471 let body = std::fs::read_to_string(dir.path().join("QWEN.md")).unwrap();
472 assert!(body.contains("Use Qwen rules."));
473 assert!(body.contains("AGENT-CONFIG:alpha"));
474 }
475
476 #[test]
477 fn rules_install_idempotent() {
478 let dir = tempdir().unwrap();
479 let agent = QwenAgent::new();
480 let scope = Scope::Local(dir.path().to_path_buf());
481 let spec = rules_spec("alpha", "rules");
482 agent.install(&scope, &spec).unwrap();
483 let r2 = agent.install(&scope, &spec).unwrap();
484 assert!(r2.already_installed);
485 }
486
487 #[test]
488 fn install_uninstall_round_trip() {
489 let dir = tempdir().unwrap();
490 let agent = QwenAgent::new();
491 let scope = Scope::Local(dir.path().to_path_buf());
492 agent
493 .install(&scope, &rules_spec("alpha", "rules"))
494 .unwrap();
495 assert!(agent.is_installed(&scope, "alpha").unwrap());
496 agent.uninstall(&scope, "alpha").unwrap();
497 assert!(!agent.is_installed(&scope, "alpha").unwrap());
498 }
499
500 #[test]
501 fn install_requires_rules() {
502 let dir = tempdir().unwrap();
503 let agent = QwenAgent::new();
504 let scope = Scope::Local(dir.path().to_path_buf());
505 let spec = HookSpec::builder("alpha")
506 .command_program("noop", [] as [&str; 0])
507 .build();
508 let err = agent.install(&scope, &spec).unwrap_err();
509 assert!(matches!(err, AgentConfigError::MissingSpecField { .. }));
510 }
511
512 #[test]
513 fn install_mcp_writes_settings_json() {
514 let dir = tempdir().unwrap();
515 let agent = QwenAgent::new();
516 let scope = Scope::Local(dir.path().to_path_buf());
517 agent
518 .install_mcp(&scope, &mcp_spec("github", "myapp"))
519 .unwrap();
520 let v = read_json(&dir.path().join(".qwen/settings.json"));
521 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
522 }
523
524 #[test]
525 fn install_mcp_idempotent() {
526 let dir = tempdir().unwrap();
527 let agent = QwenAgent::new();
528 let scope = Scope::Local(dir.path().to_path_buf());
529 let s = mcp_spec("github", "myapp");
530 agent.install_mcp(&scope, &s).unwrap();
531 let r = agent.install_mcp(&scope, &s).unwrap();
532 assert!(r.already_installed);
533 }
534
535 #[test]
536 fn install_skill_writes_skills_dir() {
537 let dir = tempdir().unwrap();
538 let agent = QwenAgent::new();
539 let scope = Scope::Local(dir.path().to_path_buf());
540 agent
541 .install_skill(&scope, &skill("alpha-skill", "myapp"))
542 .unwrap();
543 assert!(dir
544 .path()
545 .join(".qwen/skills/alpha-skill/SKILL.md")
546 .exists());
547 }
548
549 #[test]
550 fn skill_owner_mismatch_refused() {
551 let dir = tempdir().unwrap();
552 let agent = QwenAgent::new();
553 let scope = Scope::Local(dir.path().to_path_buf());
554 agent
555 .install_skill(&scope, &skill("alpha-skill", "app-a"))
556 .unwrap();
557 let err = agent
558 .install_skill(&scope, &skill("alpha-skill", "app-b"))
559 .unwrap_err();
560 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
561 }
562
563 #[test]
564 fn plan_install_does_not_write() {
565 let dir = tempdir().unwrap();
566 let agent = QwenAgent::new();
567 let scope = Scope::Local(dir.path().to_path_buf());
568 let spec = rules_spec("alpha", "rules");
569 let _plan = agent.plan_install(&scope, &spec).unwrap();
570 assert!(!dir.path().join("QWEN.md").exists());
571 }
572}