Skip to main content

cmx_core/
platform.rs

1use std::fmt;
2use std::path::PathBuf;
3
4use crate::types::{ArtifactKind, InstallScope};
5
6// --- private spec types ---
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum AgentFormat {
10    Markdown,
11    Toml,
12}
13
14struct PlatformSpec {
15    name: &'static str,
16    slug: &'static str,
17    /// `Some` only for platforms that define a plugin-manifest format (used by
18    /// cmf). `None` for `.agents`-standard tools, which have no such format.
19    manifest_dir: Option<&'static str>,
20    /// `Some` for platforms that support file-droppable agents; the variant
21    /// selects the installed format. `None` for skills-only tools.
22    agent_format: Option<AgentFormat>,
23}
24
25// --- enum ---
26
27/// The target AI coding assistant platform for artifact installation.
28///
29/// Serializes to its lowercase name (`"claude"`, `"codex"`, …) — the same token
30/// the `--platform` flag accepts — so the `platforms` list in `config.json`
31/// stays human-readable and round-trips with the CLI.
32#[derive(
33    Debug,
34    Clone,
35    Copy,
36    PartialEq,
37    Eq,
38    Hash,
39    Default,
40    clap::ValueEnum,
41    serde::Serialize,
42    serde::Deserialize,
43)]
44#[serde(rename_all = "lowercase")]
45pub enum Platform {
46    #[default]
47    Claude,
48    Copilot,
49    Cursor,
50    Windsurf,
51    Gemini,
52    /// opencode — markdown agents; skills in the shared .agents directory.
53    Opencode,
54    /// Codex CLI — TOML agents; skills in the shared .agents directory.
55    Codex,
56    /// Pi — skills only; no native agent concept.
57    Pi,
58    /// Crush — skills only; reads the shared .agents directory.
59    Crush,
60    /// Amp — skills only; reads the shared .agents directory.
61    Amp,
62    /// Zed — skills only; agents are settings-embedded profiles cmx does not manage.
63    Zed,
64    /// openhands — skills only; agents are trigger-activated skills.
65    Openhands,
66    /// Hermes — skills only; global-centric (~/.hermes/skills); no agent files.
67    Hermes,
68    /// Devin — skills only; discovers `SKILL.md` in connected repos and reads
69    /// the shared .agents directory (its recommended location); no agent files.
70    Devin,
71}
72
73impl fmt::Display for Platform {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(self.spec().name)
76    }
77}
78
79impl Platform {
80    /// Single authoritative per-variant data table.
81    ///
82    /// The exhaustive `match` forces any new variant to be listed here — the
83    /// compiler rejects a build that adds a 14th variant without filling in its
84    /// spec, eliminating the need for parallel matches elsewhere.
85    fn spec(self) -> PlatformSpec {
86        match self {
87            // Claude is the canonical manifest *source* (`.claude-plugin/`), not a
88            // target — cmf reads from it but never writes back to it.
89            Self::Claude => PlatformSpec {
90                name: "claude",
91                slug: "",
92                manifest_dir: None,
93                agent_format: Some(AgentFormat::Markdown),
94            },
95            Self::Copilot => PlatformSpec {
96                name: "copilot",
97                slug: "copilot",
98                manifest_dir: Some(".copilot-plugin"),
99                agent_format: Some(AgentFormat::Markdown),
100            },
101            Self::Cursor => PlatformSpec {
102                name: "cursor",
103                slug: "cursor",
104                manifest_dir: Some(".cursor-plugin"),
105                agent_format: Some(AgentFormat::Markdown),
106            },
107            Self::Windsurf => PlatformSpec {
108                name: "windsurf",
109                slug: "windsurf",
110                manifest_dir: Some(".windsurf-plugin"),
111                agent_format: Some(AgentFormat::Markdown),
112            },
113            Self::Gemini => PlatformSpec {
114                name: "gemini",
115                slug: "gemini",
116                manifest_dir: Some(".gemini-plugin"),
117                agent_format: Some(AgentFormat::Markdown),
118            },
119            Self::Opencode => PlatformSpec {
120                name: "opencode",
121                slug: "opencode",
122                manifest_dir: None,
123                agent_format: Some(AgentFormat::Markdown),
124            },
125            Self::Codex => PlatformSpec {
126                name: "codex",
127                slug: "codex",
128                manifest_dir: None,
129                agent_format: Some(AgentFormat::Toml),
130            },
131            Self::Pi => PlatformSpec {
132                name: "pi",
133                slug: "pi",
134                manifest_dir: None,
135                agent_format: None,
136            },
137            Self::Crush => PlatformSpec {
138                name: "crush",
139                slug: "crush",
140                manifest_dir: None,
141                agent_format: None,
142            },
143            Self::Amp => PlatformSpec {
144                name: "amp",
145                slug: "amp",
146                manifest_dir: None,
147                agent_format: None,
148            },
149            Self::Zed => PlatformSpec {
150                name: "zed",
151                slug: "zed",
152                manifest_dir: None,
153                agent_format: None,
154            },
155            Self::Openhands => PlatformSpec {
156                name: "openhands",
157                slug: "openhands",
158                manifest_dir: None,
159                agent_format: None,
160            },
161            Self::Hermes => PlatformSpec {
162                name: "hermes",
163                slug: "hermes",
164                manifest_dir: None,
165                agent_format: None,
166            },
167            Self::Devin => PlatformSpec {
168                name: "devin",
169                slug: "devin",
170                manifest_dir: None,
171                agent_format: None,
172            },
173        }
174    }
175
176    /// The install directory for the given artifact kind and scope.
177    ///
178    /// The returned path is relative — to the project root for
179    /// [`InstallScope::Local`], or to `$HOME` for [`InstallScope::Global`]. It
180    /// already includes the kind-specific leaf directory (e.g. `agents`,
181    /// `skills`), so callers do not append a subdirectory.
182    ///
183    /// Returns `None` for unsupported `(platform, kind)` combinations (see
184    /// [`supports`]). Callers should gate on `supports` or `ensure_supports`
185    /// before calling this, so `None` indicates a programming error at the call
186    /// site.
187    ///
188    /// [`supports`]: Platform::supports
189    pub fn install_subpath(self, kind: ArtifactKind, scope: InstallScope) -> Option<PathBuf> {
190        use ArtifactKind::{Agent, Skill};
191
192        let dir = |base: &str, leaf: &str| PathBuf::from(base).join(leaf);
193        let local = scope.is_local();
194
195        match (self, kind) {
196            (Self::Claude, Agent) => Some(dir(".claude", "agents")),
197            (Self::Claude, Skill) => Some(dir(".claude", "skills")),
198
199            // Copilot reads project files from `.github` but stores user-scoped
200            // artifacts under `.copilot`.
201            (Self::Copilot, Agent) => {
202                Some(dir(if local { ".github" } else { ".copilot" }, "agents"))
203            }
204            (Self::Copilot, Skill) => {
205                Some(dir(if local { ".github" } else { ".copilot" }, "skills"))
206            }
207
208            (Self::Cursor, Agent) => Some(dir(".cursor", "agents")),
209            (Self::Cursor, Skill) => Some(dir(".cursor", "skills")),
210
211            // Windsurf nests user-scoped artifacts under `.codeium/windsurf`.
212            (Self::Windsurf, Agent) if local => Some(dir(".windsurf", "agents")),
213            (Self::Windsurf, Skill) if local => Some(dir(".windsurf", "skills")),
214            (Self::Windsurf, Agent) => {
215                Some(PathBuf::from(".codeium").join("windsurf").join("agents"))
216            }
217            (Self::Windsurf, Skill) => {
218                Some(PathBuf::from(".codeium").join("windsurf").join("skills"))
219            }
220
221            (Self::Gemini, Agent) => Some(dir(".gemini", "agents")),
222            (Self::Gemini, Skill) => Some(dir(".gemini", "skills")),
223
224            // opencode: markdown agents in `.opencode/agent` (singular leaf);
225            // user-scoped config lives under `~/.config/opencode`.
226            (Self::Opencode, Agent) if local => Some(dir(".opencode", "agent")),
227            (Self::Opencode, Agent) => {
228                Some(PathBuf::from(".config").join("opencode").join("agent"))
229            }
230
231            // codex: TOML agents in `.codex/agents` (both scopes).
232            (Self::Codex, Agent) => Some(dir(".codex", "agents")),
233
234            // Skills-only tools have no droppable agent concept.
235            (
236                Self::Pi
237                | Self::Crush
238                | Self::Amp
239                | Self::Zed
240                | Self::Openhands
241                | Self::Hermes
242                | Self::Devin,
243                Agent,
244            ) => None,
245
246            // Amp and Hermes diverge only at *global* scope.
247            (Self::Amp, Skill) if !local => {
248                Some(PathBuf::from(".config").join("agents").join("skills"))
249            }
250            (Self::Hermes, Skill) if !local => Some(dir(".hermes", "skills")),
251
252            // All `.agents`-standard tools (plus Amp/Hermes at project scope)
253            // resolve skills to the shared cross-tool `.agents/skills` location.
254            (
255                Self::Opencode
256                | Self::Codex
257                | Self::Pi
258                | Self::Crush
259                | Self::Zed
260                | Self::Openhands
261                | Self::Devin
262                | Self::Amp
263                | Self::Hermes,
264                Skill,
265            ) => Some(PathBuf::from(".agents").join("skills")),
266        }
267    }
268
269    /// Whether this platform supports installing the given artifact kind.
270    ///
271    /// Skills-only tools (Pi, Crush, Amp, Zed, `OpenHands`, Hermes) have no
272    /// file-droppable agent concept; `(platform, Agent)` is unsupported for
273    /// them. Derived from [`spec`](Self::spec) — a `None` `agent_format` means
274    /// skills-only.
275    pub fn supports(self, kind: ArtifactKind) -> bool {
276        kind == ArtifactKind::Skill || self.spec().agent_format.is_some()
277    }
278
279    /// The file extension for an installed agent on this platform.
280    ///
281    /// Most platforms store agents as markdown (`md`); codex uses TOML (`toml`)
282    /// and the source markdown is transformed during install.
283    pub fn agent_extension(self) -> &'static str {
284        match self.spec().agent_format {
285            Some(AgentFormat::Toml) => "toml",
286            _ => "md",
287        }
288    }
289
290    /// Whether installing an agent for this platform requires transforming the
291    /// source markdown into codex's TOML subagent format.
292    pub fn transforms_agent_to_toml(self) -> bool {
293        matches!(self.spec().agent_format, Some(AgentFormat::Toml))
294    }
295
296    /// Slug used to construct platform-specific lock file names.
297    ///
298    /// Claude returns an empty string (lock file stays `cmx-lock.json` for
299    /// backward compatibility). All other platforms return a non-empty slug.
300    pub fn slug(self) -> &'static str {
301        self.spec().slug
302    }
303
304    /// The directory name for this platform's plugin manifest (used by cmf).
305    ///
306    /// Returns `Some` only for platforms that receive generated manifest copies
307    /// (Copilot, Cursor, Windsurf, Gemini). Returns `None` for Claude (which is
308    /// the canonical manifest *source*, not a target) and for all
309    /// `.agents`-standard tools (opencode, codex, pi, Crush, Amp, Zed,
310    /// `OpenHands`, Hermes), which have no plugin-manifest format.
311    ///
312    /// Use [`targets`](Platform::targets) to iterate only the `Some` platforms.
313    pub fn manifest_dir(self) -> Option<&'static str> {
314        self.spec().manifest_dir
315    }
316
317    /// Every platform variant, for exhaustive cross-platform operations such as
318    /// the system survey (`cmx doctor`).
319    ///
320    /// Keep this in sync with the enum; the `all_contains_every_variant` test
321    /// guards against a variant being added without being listed here.
322    pub const ALL: [Platform; 14] = [
323        Self::Claude,
324        Self::Copilot,
325        Self::Cursor,
326        Self::Windsurf,
327        Self::Gemini,
328        Self::Opencode,
329        Self::Codex,
330        Self::Pi,
331        Self::Crush,
332        Self::Amp,
333        Self::Zed,
334        Self::Openhands,
335        Self::Hermes,
336        Self::Devin,
337    ];
338
339    /// All platforms that receive generated plugin manifests.
340    ///
341    /// Derived from `ALL` by filtering for platforms where
342    /// [`manifest_dir`](Self::manifest_dir) is `Some`. The `.agents`-standard
343    /// tools (opencode, codex, pi, Crush, Amp, Zed, `OpenHands`, Hermes) are
344    /// excluded: none of them define a plugin/marketplace manifest format.
345    pub fn targets() -> Vec<Platform> {
346        Self::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect()
347    }
348
349    /// All manifest-target platforms paired with their manifest directory name.
350    ///
351    /// Each tuple `(platform, dir)` is guaranteed to have a non-empty `dir`
352    /// string — callers never need to handle an `Option`. Use this instead of
353    /// `targets()` when the manifest directory name is needed alongside the
354    /// platform, to avoid unwrapping `manifest_dir()` at each call site.
355    pub fn manifest_targets() -> Vec<(Platform, &'static str)> {
356        Self::ALL.iter().filter_map(|p| p.manifest_dir().map(|dir| (*p, dir))).collect()
357    }
358}
359
360/// A human-readable, comma-separated label for a set of platforms
361/// (e.g. `"claude, codex"`).
362///
363/// The single home for what was an identical `platforms_label`/`join_platforms`
364/// helper copy-pasted across the display modules and the sync core.
365pub fn platforms_label(platforms: &[Platform]) -> String {
366    platforms.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ")
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn default_is_claude() {
375        assert_eq!(Platform::default(), Platform::Claude);
376    }
377
378    #[test]
379    fn display_produces_lowercase_names() {
380        assert_eq!(Platform::Claude.to_string(), "claude");
381        assert_eq!(Platform::Copilot.to_string(), "copilot");
382        assert_eq!(Platform::Cursor.to_string(), "cursor");
383        assert_eq!(Platform::Windsurf.to_string(), "windsurf");
384        assert_eq!(Platform::Gemini.to_string(), "gemini");
385        assert_eq!(Platform::Opencode.to_string(), "opencode");
386        assert_eq!(Platform::Codex.to_string(), "codex");
387        assert_eq!(Platform::Pi.to_string(), "pi");
388        assert_eq!(Platform::Crush.to_string(), "crush");
389        assert_eq!(Platform::Amp.to_string(), "amp");
390        assert_eq!(Platform::Zed.to_string(), "zed");
391        assert_eq!(Platform::Openhands.to_string(), "openhands");
392        assert_eq!(Platform::Hermes.to_string(), "hermes");
393        assert_eq!(Platform::Devin.to_string(), "devin");
394    }
395
396    #[test]
397    fn manifest_dir_values() {
398        // Manifest targets receive generated copies.
399        assert_eq!(Platform::Copilot.manifest_dir(), Some(".copilot-plugin"));
400        assert_eq!(Platform::Cursor.manifest_dir(), Some(".cursor-plugin"));
401        assert_eq!(Platform::Windsurf.manifest_dir(), Some(".windsurf-plugin"));
402        assert_eq!(Platform::Gemini.manifest_dir(), Some(".gemini-plugin"));
403        // Claude is the manifest *source*, not a target.
404        assert_eq!(Platform::Claude.manifest_dir(), None);
405    }
406
407    #[test]
408    fn manifest_dir_none_for_non_targets() {
409        for p in [
410            Platform::Claude,
411            Platform::Opencode,
412            Platform::Codex,
413            Platform::Pi,
414            Platform::Crush,
415            Platform::Amp,
416            Platform::Zed,
417            Platform::Openhands,
418            Platform::Hermes,
419            Platform::Devin,
420        ] {
421            assert!(
422                p.manifest_dir().is_none(),
423                "{p} is not a manifest target; manifest_dir() must be None"
424            );
425        }
426    }
427
428    #[test]
429    fn targets_is_derived_from_all_with_manifest_dir() {
430        let expected: Vec<Platform> =
431            Platform::ALL.iter().filter(|p| p.manifest_dir().is_some()).copied().collect();
432        assert_eq!(
433            Platform::targets(),
434            expected,
435            "targets() must equal ALL filtered by manifest_dir"
436        );
437    }
438
439    #[test]
440    fn manifest_targets_pairs_each_target_with_its_dir() {
441        let targets = Platform::targets();
442        let manifest_targets = Platform::manifest_targets();
443        assert_eq!(
444            targets.len(),
445            manifest_targets.len(),
446            "manifest_targets() must yield the same platforms as targets()"
447        );
448        for (p, dir) in &manifest_targets {
449            assert_eq!(
450                p.manifest_dir().unwrap(),
451                *dir,
452                "{p}: manifest_targets dir must equal manifest_dir().unwrap()"
453            );
454            assert!(!dir.is_empty(), "{p}: manifest_targets dir must be non-empty");
455        }
456        let platforms: Vec<Platform> = manifest_targets.iter().map(|(p, _)| *p).collect();
457        assert_eq!(platforms, targets, "manifest_targets() platforms must match targets() order");
458    }
459
460    #[test]
461    fn targets_includes_windsurf() {
462        assert!(
463            Platform::targets().contains(&Platform::Windsurf),
464            "expected Windsurf in targets()"
465        );
466    }
467
468    #[test]
469    fn targets_excludes_claude() {
470        assert!(
471            !Platform::targets().contains(&Platform::Claude),
472            "Claude should not be in targets()"
473        );
474    }
475
476    #[test]
477    fn targets_excludes_agents_standard_tools() {
478        for p in [
479            Platform::Opencode,
480            Platform::Codex,
481            Platform::Pi,
482            Platform::Crush,
483            Platform::Amp,
484            Platform::Zed,
485            Platform::Openhands,
486            Platform::Hermes,
487            Platform::Devin,
488        ] {
489            assert!(
490                !Platform::targets().contains(&p),
491                "{p} has no manifest format and must not be a manifest target"
492            );
493        }
494    }
495
496    // --- install_subpath: existing platforms (behavior preserved) ---
497
498    #[test]
499    fn install_subpath_claude() {
500        assert_eq!(
501            Platform::Claude
502                .install_subpath(ArtifactKind::Agent, InstallScope::Local)
503                .unwrap(),
504            PathBuf::from(".claude").join("agents")
505        );
506        assert_eq!(
507            Platform::Claude
508                .install_subpath(ArtifactKind::Skill, InstallScope::Global)
509                .unwrap(),
510            PathBuf::from(".claude").join("skills")
511        );
512    }
513
514    #[test]
515    fn install_subpath_copilot_differs_by_scope() {
516        assert_eq!(
517            Platform::Copilot
518                .install_subpath(ArtifactKind::Agent, InstallScope::Local)
519                .unwrap(),
520            PathBuf::from(".github").join("agents")
521        );
522        assert_eq!(
523            Platform::Copilot
524                .install_subpath(ArtifactKind::Agent, InstallScope::Global)
525                .unwrap(),
526            PathBuf::from(".copilot").join("agents")
527        );
528    }
529
530    #[test]
531    fn install_subpath_windsurf_global_nests_under_codeium() {
532        assert_eq!(
533            Platform::Windsurf
534                .install_subpath(ArtifactKind::Skill, InstallScope::Global)
535                .unwrap(),
536            PathBuf::from(".codeium").join("windsurf").join("skills")
537        );
538        assert_eq!(
539            Platform::Windsurf
540                .install_subpath(ArtifactKind::Agent, InstallScope::Local)
541                .unwrap(),
542            PathBuf::from(".windsurf").join("agents")
543        );
544    }
545
546    // --- install_subpath: new platforms ---
547
548    #[test]
549    fn install_subpath_opencode_agent_uses_singular_leaf_and_xdg_global() {
550        assert_eq!(
551            Platform::Opencode
552                .install_subpath(ArtifactKind::Agent, InstallScope::Local)
553                .unwrap(),
554            PathBuf::from(".opencode").join("agent")
555        );
556        assert_eq!(
557            Platform::Opencode
558                .install_subpath(ArtifactKind::Agent, InstallScope::Global)
559                .unwrap(),
560            PathBuf::from(".config").join("opencode").join("agent")
561        );
562    }
563
564    #[test]
565    fn install_subpath_codex_agent_uses_dot_codex_agents() {
566        assert_eq!(
567            Platform::Codex
568                .install_subpath(ArtifactKind::Agent, InstallScope::Local)
569                .unwrap(),
570            PathBuf::from(".codex").join("agents")
571        );
572        assert_eq!(
573            Platform::Codex
574                .install_subpath(ArtifactKind::Agent, InstallScope::Global)
575                .unwrap(),
576            PathBuf::from(".codex").join("agents")
577        );
578    }
579
580    #[test]
581    fn install_subpath_new_tools_share_dot_agents_skills() {
582        for p in [
583            Platform::Opencode,
584            Platform::Codex,
585            Platform::Pi,
586            Platform::Crush,
587            Platform::Zed,
588            Platform::Openhands,
589            Platform::Devin,
590        ] {
591            for scope in InstallScope::ALL {
592                assert_eq!(
593                    p.install_subpath(ArtifactKind::Skill, scope).unwrap(),
594                    PathBuf::from(".agents").join("skills"),
595                    "{p} skills (scope {scope:?}) should resolve to .agents/skills"
596                );
597            }
598        }
599    }
600
601    #[test]
602    fn install_subpath_amp_skill_diverges_at_global_scope() {
603        assert_eq!(
604            Platform::Amp.install_subpath(ArtifactKind::Skill, InstallScope::Local).unwrap(),
605            PathBuf::from(".agents").join("skills")
606        );
607        assert_eq!(
608            Platform::Amp
609                .install_subpath(ArtifactKind::Skill, InstallScope::Global)
610                .unwrap(),
611            PathBuf::from(".config").join("agents").join("skills")
612        );
613    }
614
615    #[test]
616    fn install_subpath_hermes_skill_diverges_at_global_scope() {
617        assert_eq!(
618            Platform::Hermes
619                .install_subpath(ArtifactKind::Skill, InstallScope::Local)
620                .unwrap(),
621            PathBuf::from(".agents").join("skills")
622        );
623        assert_eq!(
624            Platform::Hermes
625                .install_subpath(ArtifactKind::Skill, InstallScope::Global)
626                .unwrap(),
627            PathBuf::from(".hermes").join("skills")
628        );
629    }
630
631    #[test]
632    fn install_subpath_skills_only_tools_return_none_for_agent() {
633        for p in [
634            Platform::Pi,
635            Platform::Crush,
636            Platform::Amp,
637            Platform::Zed,
638            Platform::Openhands,
639            Platform::Hermes,
640            Platform::Devin,
641        ] {
642            for scope in InstallScope::ALL {
643                assert!(
644                    p.install_subpath(ArtifactKind::Agent, scope).is_none(),
645                    "{p} has no agent concept; install_subpath(Agent, {scope:?}) must be None"
646                );
647            }
648        }
649    }
650
651    #[test]
652    fn supports_and_install_subpath_agent_agree_for_all_variants() {
653        // supports(Agent) == install_subpath(Agent, _).is_some() must hold for
654        // every platform at every scope — the two predicates must never diverge.
655        for p in Platform::ALL {
656            for scope in InstallScope::ALL {
657                let has_path = p.install_subpath(ArtifactKind::Agent, scope).is_some();
658                let supported = p.supports(ArtifactKind::Agent);
659                assert_eq!(
660                    has_path, supported,
661                    "{p}: supports(Agent)={supported} but install_subpath(Agent, {scope:?}).is_some()={has_path}"
662                );
663            }
664        }
665    }
666
667    // --- supports ---
668
669    #[test]
670    fn supports_skills_only_tools_reject_agents() {
671        for p in [
672            Platform::Pi,
673            Platform::Crush,
674            Platform::Amp,
675            Platform::Zed,
676            Platform::Openhands,
677            Platform::Hermes,
678            Platform::Devin,
679        ] {
680            assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
681            assert!(!p.supports(ArtifactKind::Agent), "{p} should not support agents");
682        }
683    }
684
685    #[test]
686    fn supports_all_kinds_for_other_platforms() {
687        for p in [
688            Platform::Claude,
689            Platform::Copilot,
690            Platform::Cursor,
691            Platform::Windsurf,
692            Platform::Gemini,
693            Platform::Opencode,
694            Platform::Codex,
695        ] {
696            assert!(p.supports(ArtifactKind::Agent), "{p} should support agents");
697            assert!(p.supports(ArtifactKind::Skill), "{p} should support skills");
698        }
699    }
700
701    // --- agent_extension / transform ---
702
703    #[test]
704    fn agent_extension_codex_is_toml_others_md() {
705        assert_eq!(Platform::Codex.agent_extension(), "toml");
706        assert_eq!(Platform::Claude.agent_extension(), "md");
707        assert_eq!(Platform::Opencode.agent_extension(), "md");
708        assert_eq!(Platform::Pi.agent_extension(), "md");
709    }
710
711    #[test]
712    fn only_codex_transforms_agents() {
713        assert!(Platform::Codex.transforms_agent_to_toml());
714        assert!(!Platform::Claude.transforms_agent_to_toml());
715        assert!(!Platform::Opencode.transforms_agent_to_toml());
716    }
717
718    // --- slug ---
719
720    #[test]
721    fn serializes_to_lowercase_cli_name() {
722        // The config `platforms` list must round-trip with the tokens the
723        // `--platform` flag and `cmx config platforms add` accept.
724        assert_eq!(serde_json::to_string(&Platform::Codex).unwrap(), "\"codex\"");
725        assert_eq!(serde_json::to_string(&Platform::Claude).unwrap(), "\"claude\"");
726        assert_eq!(serde_json::from_str::<Platform>("\"openhands\"").unwrap(), Platform::Openhands);
727    }
728
729    #[test]
730    fn slug_values() {
731        assert_eq!(Platform::Claude.slug(), "");
732        assert_eq!(Platform::Copilot.slug(), "copilot");
733        assert_eq!(Platform::Cursor.slug(), "cursor");
734        assert_eq!(Platform::Windsurf.slug(), "windsurf");
735        assert_eq!(Platform::Gemini.slug(), "gemini");
736        assert_eq!(Platform::Opencode.slug(), "opencode");
737        assert_eq!(Platform::Codex.slug(), "codex");
738        assert_eq!(Platform::Pi.slug(), "pi");
739        assert_eq!(Platform::Crush.slug(), "crush");
740        assert_eq!(Platform::Amp.slug(), "amp");
741        assert_eq!(Platform::Zed.slug(), "zed");
742        assert_eq!(Platform::Openhands.slug(), "openhands");
743        assert_eq!(Platform::Hermes.slug(), "hermes");
744        assert_eq!(Platform::Devin.slug(), "devin");
745    }
746
747    // --- ALL ---
748
749    #[test]
750    fn all_contains_every_variant() {
751        let every = [
752            Platform::Claude,
753            Platform::Copilot,
754            Platform::Cursor,
755            Platform::Windsurf,
756            Platform::Gemini,
757            Platform::Opencode,
758            Platform::Codex,
759            Platform::Pi,
760            Platform::Crush,
761            Platform::Amp,
762            Platform::Zed,
763            Platform::Openhands,
764            Platform::Hermes,
765            Platform::Devin,
766        ];
767        assert_eq!(Platform::ALL.len(), every.len(), "ALL must list every variant");
768        for p in every {
769            assert!(Platform::ALL.contains(&p), "{p} missing from Platform::ALL");
770        }
771    }
772
773    #[test]
774    fn all_slugs_are_unique() {
775        let mut slugs: Vec<&str> = Platform::ALL.iter().map(|p| p.slug()).collect();
776        let count = slugs.len();
777        slugs.sort_unstable();
778        slugs.dedup();
779        assert_eq!(slugs.len(), count, "platform slugs must be unique across ALL");
780    }
781
782    #[test]
783    fn value_enum_parses_all_variants() {
784        use clap::ValueEnum;
785        assert_eq!(Platform::from_str("claude", true).unwrap(), Platform::Claude);
786        assert_eq!(Platform::from_str("copilot", true).unwrap(), Platform::Copilot);
787        assert_eq!(Platform::from_str("cursor", true).unwrap(), Platform::Cursor);
788        assert_eq!(Platform::from_str("windsurf", true).unwrap(), Platform::Windsurf);
789        assert_eq!(Platform::from_str("gemini", true).unwrap(), Platform::Gemini);
790        assert_eq!(Platform::from_str("opencode", true).unwrap(), Platform::Opencode);
791        assert_eq!(Platform::from_str("codex", true).unwrap(), Platform::Codex);
792        assert_eq!(Platform::from_str("pi", true).unwrap(), Platform::Pi);
793        assert_eq!(Platform::from_str("crush", true).unwrap(), Platform::Crush);
794        assert_eq!(Platform::from_str("amp", true).unwrap(), Platform::Amp);
795        assert_eq!(Platform::from_str("zed", true).unwrap(), Platform::Zed);
796        assert_eq!(Platform::from_str("openhands", true).unwrap(), Platform::Openhands);
797        assert_eq!(Platform::from_str("hermes", true).unwrap(), Platform::Hermes);
798        assert_eq!(Platform::from_str("devin", true).unwrap(), Platform::Devin);
799    }
800}