Skip to main content

vfa_tui/models/
export.rs

1use std::path::PathBuf;
2
3use crate::error::TuiError;
4use crate::security::validate::validate_argument;
5
6/// Represents the selection criteria for export operations.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum ExportSelection {
9    /// Export all agents.
10    All,
11    /// Export agents for a specific role.
12    Role(String),
13    /// Export agents from a specific provider.
14    Provider(String),
15    /// Export specific agents by ID.
16    Agents(Vec<String>),
17}
18
19/// Represents an export command to be executed as a subprocess.
20#[derive(Debug, Clone)]
21pub struct ExportCommand {
22    pub platform: String,
23    pub selection: ExportSelection,
24    pub target_repo: PathBuf,
25    pub dry_run: bool,
26    pub force: bool,
27    pub no_skills: bool,
28}
29
30impl ExportCommand {
31    /// Create a new export command with default options (dry_run=true, force=false, no_skills=false).
32    pub fn new(platform: String, selection: ExportSelection, target_repo: PathBuf) -> Self {
33        Self {
34            platform,
35            selection,
36            target_repo,
37            dry_run: true,
38            force: false,
39            no_skills: false,
40        }
41    }
42
43    /// Validate all user-provided arguments. Returns an error if any argument
44    /// contains shell metacharacters or other forbidden characters.
45    pub fn validate(&self) -> Result<(), TuiError> {
46        validate_argument(&self.platform)?;
47
48        match &self.selection {
49            ExportSelection::All => {}
50            ExportSelection::Role(role) => {
51                validate_argument(role)?;
52            }
53            ExportSelection::Provider(provider) => {
54                validate_argument(provider)?;
55            }
56            ExportSelection::Agents(ids) => {
57                for id in ids {
58                    validate_argument(id)?;
59                }
60            }
61        }
62
63        let target_str = self.target_repo.to_string_lossy();
64        validate_argument(&target_str)?;
65
66        Ok(())
67    }
68
69    /// Build the argument array for subprocess invocation.
70    ///
71    /// The command is: `node scripts/export-marketplace-agents.mjs <args>`
72    pub fn to_args(&self) -> Vec<String> {
73        let mut args = Vec::new();
74
75        args.push("--platform".to_string());
76        args.push(self.platform.clone());
77
78        match &self.selection {
79            ExportSelection::All => {
80                args.push("--all".to_string());
81            }
82            ExportSelection::Role(role) => {
83                args.push("--role".to_string());
84                args.push(role.clone());
85            }
86            ExportSelection::Provider(provider) => {
87                args.push("--provider".to_string());
88                args.push(provider.clone());
89            }
90            ExportSelection::Agents(ids) => {
91                args.push(format!("--agents={}", ids.join(",")));
92            }
93        }
94
95        args.push("--repo".to_string());
96        args.push(self.target_repo.to_string_lossy().to_string());
97
98        if self.dry_run {
99            args.push("--dry-run".to_string());
100        }
101
102        if self.force {
103            args.push("--force".to_string());
104        }
105
106        if self.no_skills {
107            args.push("--no-skills".to_string());
108        }
109
110        args
111    }
112
113    /// Render the full command string for display/preview.
114    pub fn display_command(&self) -> String {
115        format!(
116            "node scripts/export-marketplace-agents.mjs {}",
117            self.to_args().join(" ")
118        )
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn to_args_all_selection() {
128        let cmd = ExportCommand::new(
129            "kiro".to_string(),
130            ExportSelection::All,
131            PathBuf::from("/tmp/target"),
132        );
133        let args = cmd.to_args();
134        assert!(args.contains(&"--platform".to_string()));
135        assert!(args.contains(&"kiro".to_string()));
136        assert!(args.contains(&"--all".to_string()));
137        assert!(args.contains(&"--repo".to_string()));
138        assert!(args.contains(&"/tmp/target".to_string()));
139        assert!(args.contains(&"--dry-run".to_string()));
140        assert!(!args.contains(&"--force".to_string()));
141        assert!(!args.contains(&"--no-skills".to_string()));
142    }
143
144    #[test]
145    fn to_args_role_selection() {
146        let cmd = ExportCommand::new(
147            "cursor".to_string(),
148            ExportSelection::Role("devops-engineer".to_string()),
149            PathBuf::from("/tmp/target"),
150        );
151        let args = cmd.to_args();
152        assert!(args.contains(&"--role".to_string()));
153        assert!(args.contains(&"devops-engineer".to_string()));
154    }
155
156    #[test]
157    fn to_args_provider_selection() {
158        let cmd = ExportCommand::new(
159            "claude".to_string(),
160            ExportSelection::Provider("aws".to_string()),
161            PathBuf::from("/tmp/target"),
162        );
163        let args = cmd.to_args();
164        assert!(args.contains(&"--provider".to_string()));
165        assert!(args.contains(&"aws".to_string()));
166    }
167
168    #[test]
169    fn to_args_agents_selection() {
170        let cmd = ExportCommand::new(
171            "kiro".to_string(),
172            ExportSelection::Agents(vec!["agent-a".to_string(), "agent-b".to_string()]),
173            PathBuf::from("/tmp/target"),
174        );
175        let args = cmd.to_args();
176        assert!(args.iter().any(|a| a == "--agents=agent-a,agent-b"));
177        assert!(!args.contains(&"--all".to_string()));
178        assert!(!args.contains(&"--role".to_string()));
179        assert!(!args.contains(&"--provider".to_string()));
180    }
181
182    #[test]
183    fn to_args_with_force_and_no_skills() {
184        let mut cmd = ExportCommand::new(
185            "kiro".to_string(),
186            ExportSelection::All,
187            PathBuf::from("/tmp/target"),
188        );
189        cmd.dry_run = false;
190        cmd.force = true;
191        cmd.no_skills = true;
192        let args = cmd.to_args();
193        assert!(!args.contains(&"--dry-run".to_string()));
194        assert!(args.contains(&"--force".to_string()));
195        assert!(args.contains(&"--no-skills".to_string()));
196    }
197
198    #[test]
199    fn display_command_format() {
200        let cmd = ExportCommand::new(
201            "kiro".to_string(),
202            ExportSelection::All,
203            PathBuf::from("/tmp/target"),
204        );
205        let display = cmd.display_command();
206        assert!(display.starts_with("node scripts/export-marketplace-agents.mjs"));
207        assert!(display.contains("--platform"));
208        assert!(display.contains("kiro"));
209    }
210
211    #[test]
212    fn no_empty_strings_in_args() {
213        let cmd = ExportCommand::new(
214            "kiro".to_string(),
215            ExportSelection::All,
216            PathBuf::from("/tmp/target"),
217        );
218        for arg in cmd.to_args() {
219            assert!(!arg.is_empty(), "found empty string in args");
220        }
221    }
222
223    #[test]
224    fn validate_accepts_safe_args() {
225        let cmd = ExportCommand::new(
226            "kiro".to_string(),
227            ExportSelection::Role("devops-engineer".to_string()),
228            PathBuf::from("/tmp/target"),
229        );
230        assert!(cmd.validate().is_ok());
231    }
232
233    #[test]
234    fn validate_rejects_platform_with_metachar() {
235        let cmd = ExportCommand::new(
236            "kiro; rm -rf /".to_string(),
237            ExportSelection::All,
238            PathBuf::from("/tmp/target"),
239        );
240        assert!(cmd.validate().is_err());
241    }
242
243    #[test]
244    fn validate_rejects_role_with_metachar() {
245        let cmd = ExportCommand::new(
246            "kiro".to_string(),
247            ExportSelection::Role("role|cat /etc/passwd".to_string()),
248            PathBuf::from("/tmp/target"),
249        );
250        assert!(cmd.validate().is_err());
251    }
252
253    #[test]
254    fn validate_rejects_agent_id_with_metachar() {
255        let cmd = ExportCommand::new(
256            "kiro".to_string(),
257            ExportSelection::Agents(vec!["good-agent".to_string(), "bad$(cmd)".to_string()]),
258            PathBuf::from("/tmp/target"),
259        );
260        assert!(cmd.validate().is_err());
261    }
262
263    #[test]
264    fn to_args_agents_selection_uses_agents_flag() {
265        let cmd = ExportCommand::new(
266            "kiro".to_string(),
267            ExportSelection::Agents(vec!["agent-a".to_string(), "agent-b".to_string()]),
268            PathBuf::from("/tmp/target"),
269        );
270        let args = cmd.to_args();
271        assert!(args.iter().any(|arg| arg == "--agents=agent-a,agent-b"));
272        assert!(!args.iter().any(|arg| arg == "agent-a"));
273    }
274}