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