Skip to main content

souk_core/ci/
install_workflows.rs

1//! CI workflow installation for various CI/CD providers.
2//!
3//! Detects which CI provider is in use (GitHub Actions, CircleCI, GitLab CI,
4//! Buildkite, or compatible providers like Blacksmith and Northflank) and
5//! generates the appropriate workflow configuration to run `souk validate marketplace`.
6
7use std::fs;
8use std::path::Path;
9
10use crate::error::SoukError;
11
12/// Supported CI providers.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum CiProvider {
15    /// GitHub Actions (`.github/workflows/`)
16    GitHub,
17    /// Blacksmith (GitHub-compatible, uses `.github/workflows/`)
18    Blacksmith,
19    /// Northflank (GitHub-compatible, uses `.github/workflows/`)
20    Northflank,
21    /// CircleCI (`.circleci/`)
22    CircleCi,
23    /// GitLab CI (`.gitlab-ci.yml`)
24    GitLab,
25    /// Buildkite (`.buildkite/`)
26    Buildkite,
27}
28
29impl CiProvider {
30    /// Returns the lowercase name of the CI provider.
31    pub fn name(&self) -> &str {
32        match self {
33            CiProvider::GitHub => "github",
34            CiProvider::Blacksmith => "blacksmith",
35            CiProvider::Northflank => "northflank",
36            CiProvider::CircleCi => "circleci",
37            CiProvider::GitLab => "gitlab",
38            CiProvider::Buildkite => "buildkite",
39        }
40    }
41}
42
43impl std::fmt::Display for CiProvider {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        write!(f, "{}", self.name())
46    }
47}
48
49/// Auto-detect the CI provider in use at the given project root.
50///
51/// Checks for configuration directories/files in priority order:
52/// 1. `.github/workflows/` directory
53/// 2. `.circleci/` directory
54/// 3. `.gitlab-ci.yml` file
55/// 4. `.buildkite/` directory
56///
57/// Returns `None` if no CI provider is detected.
58pub fn detect_ci_provider(project_root: &Path) -> Option<CiProvider> {
59    if project_root.join(".github").join("workflows").is_dir() {
60        Some(CiProvider::GitHub)
61    } else if project_root.join(".circleci").is_dir() {
62        Some(CiProvider::CircleCi)
63    } else if project_root.join(".gitlab-ci.yml").exists() {
64        Some(CiProvider::GitLab)
65    } else if project_root.join(".buildkite").is_dir() {
66        Some(CiProvider::Buildkite)
67    } else {
68        None
69    }
70}
71
72/// Install a CI workflow for the specified provider.
73///
74/// Creates the appropriate workflow configuration file.
75/// Returns a human-readable description of what was done.
76pub fn install_workflow(project_root: &Path, provider: &CiProvider) -> Result<String, SoukError> {
77    match provider {
78        CiProvider::GitHub | CiProvider::Blacksmith | CiProvider::Northflank => {
79            install_github_workflow(project_root)
80        }
81        CiProvider::CircleCi => install_circleci_config(project_root),
82        CiProvider::GitLab => install_gitlab_config(project_root),
83        CiProvider::Buildkite => install_buildkite_config(project_root),
84    }
85}
86
87/// GitHub Actions workflow template.
88const GITHUB_WORKFLOW: &str = r#"name: Souk Marketplace Validation
89
90on:
91  push:
92    paths:
93      - '.claude-plugin/**'
94      - 'plugins/**'
95  pull_request:
96    paths:
97      - '.claude-plugin/**'
98      - 'plugins/**'
99
100jobs:
101  validate:
102    runs-on: ubuntu-latest
103    steps:
104      - uses: actions/checkout@v4
105
106      - name: Install souk
107        run: cargo install souk
108
109      - name: Validate marketplace
110        run: souk validate marketplace
111"#;
112
113/// Install a GitHub Actions workflow file.
114fn install_github_workflow(project_root: &Path) -> Result<String, SoukError> {
115    let workflows_dir = project_root.join(".github").join("workflows");
116    fs::create_dir_all(&workflows_dir)?;
117
118    let workflow_path = workflows_dir.join("souk-validate.yml");
119
120    if workflow_path.exists() {
121        let existing = fs::read_to_string(&workflow_path)?;
122        if existing.contains("souk validate marketplace") {
123            return Ok(format!(
124                "GitHub Actions workflow already exists at {}",
125                workflow_path.display()
126            ));
127        }
128    }
129
130    fs::write(&workflow_path, GITHUB_WORKFLOW)?;
131
132    Ok(format!(
133        "Created GitHub Actions workflow at {}",
134        workflow_path.display()
135    ))
136}
137
138/// CircleCI configuration template.
139const CIRCLECI_CONFIG: &str = r#"version: 2.1
140
141jobs:
142  souk-validate:
143    docker:
144      - image: cimg/rust:1.80
145    steps:
146      - checkout
147      - run:
148          name: Install souk
149          command: cargo install souk
150      - run:
151          name: Validate marketplace
152          command: souk validate marketplace
153
154workflows:
155  validate:
156    jobs:
157      - souk-validate:
158          filters:
159            branches:
160              only: /.*/
161"#;
162
163/// Install a CircleCI configuration file.
164///
165/// If `.circleci/config.yml` already exists, appends the souk job as a comment
166/// to avoid overwriting existing configuration.
167fn install_circleci_config(project_root: &Path) -> Result<String, SoukError> {
168    let circleci_dir = project_root.join(".circleci");
169    fs::create_dir_all(&circleci_dir)?;
170
171    let config_path = circleci_dir.join("config.yml");
172
173    if config_path.exists() {
174        let existing = fs::read_to_string(&config_path)?;
175        if existing.contains("souk-validate") {
176            return Ok(format!(
177                "CircleCI souk-validate job already exists in {}",
178                config_path.display()
179            ));
180        }
181
182        // Append as commented section to not break existing config
183        let snippet = "\n# --- Souk validation (merge into your config) ---\n\
184             # Add the following job to your existing workflows:\n\
185             #\n\
186             # jobs:\n\
187             #   souk-validate:\n\
188             #     docker:\n\
189             #       - image: cimg/rust:1.80\n\
190             #     steps:\n\
191             #       - checkout\n\
192             #       - run:\n\
193             #           name: Install souk\n\
194             #           command: cargo install souk\n\
195             #       - run:\n\
196             #           name: Validate marketplace\n\
197             #           command: souk validate marketplace\n";
198        let new_content = format!("{existing}{snippet}");
199        fs::write(&config_path, new_content)?;
200
201        return Ok(format!(
202            "Appended souk validation job (commented) to {}. \
203             Please merge into your existing CircleCI configuration.",
204            config_path.display()
205        ));
206    }
207
208    fs::write(&config_path, CIRCLECI_CONFIG)?;
209
210    Ok(format!(
211        "Created CircleCI configuration at {}",
212        config_path.display()
213    ))
214}
215
216/// GitLab CI configuration template.
217const GITLAB_CONFIG: &str = r#"souk-validate:
218  stage: test
219  image: rust:1.80
220  script:
221    - cargo install souk
222    - souk validate marketplace
223  rules:
224    - changes:
225        - .claude-plugin/**/*
226        - plugins/**/*
227"#;
228
229/// Install a GitLab CI configuration.
230///
231/// If `.gitlab-ci.yml` already exists, appends the souk job.
232fn install_gitlab_config(project_root: &Path) -> Result<String, SoukError> {
233    let config_path = project_root.join(".gitlab-ci.yml");
234
235    if config_path.exists() {
236        let existing = fs::read_to_string(&config_path)?;
237        if existing.contains("souk-validate") {
238            return Ok(format!(
239                "GitLab CI souk-validate job already exists in {}",
240                config_path.display()
241            ));
242        }
243
244        let new_content = format!("{existing}\n{GITLAB_CONFIG}");
245        fs::write(&config_path, new_content)?;
246
247        return Ok(format!(
248            "Appended souk-validate job to {}",
249            config_path.display()
250        ));
251    }
252
253    fs::write(&config_path, GITLAB_CONFIG)?;
254
255    Ok(format!(
256        "Created GitLab CI configuration at {}",
257        config_path.display()
258    ))
259}
260
261/// Buildkite pipeline template.
262const BUILDKITE_PIPELINE: &str = r#"steps:
263  - label: ":souk: Validate Marketplace"
264    command:
265      - cargo install souk
266      - souk validate marketplace
267    agents:
268      queue: default
269"#;
270
271/// Install a Buildkite pipeline configuration.
272fn install_buildkite_config(project_root: &Path) -> Result<String, SoukError> {
273    let buildkite_dir = project_root.join(".buildkite");
274    fs::create_dir_all(&buildkite_dir)?;
275
276    let pipeline_path = buildkite_dir.join("pipeline.yml");
277
278    if pipeline_path.exists() {
279        let existing = fs::read_to_string(&pipeline_path)?;
280        if existing.contains("souk validate marketplace")
281            || existing.contains("Validate Marketplace")
282        {
283            return Ok(format!(
284                "Buildkite souk validation step already exists in {}",
285                pipeline_path.display()
286            ));
287        }
288    }
289
290    fs::write(&pipeline_path, BUILDKITE_PIPELINE)?;
291
292    Ok(format!(
293        "Created Buildkite pipeline at {}",
294        pipeline_path.display()
295    ))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use tempfile::TempDir;
302
303    #[test]
304    fn detect_ci_provider_finds_github_workflows() {
305        let tmp = TempDir::new().unwrap();
306        fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap();
307        assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitHub));
308    }
309
310    #[test]
311    fn detect_ci_provider_finds_circleci() {
312        let tmp = TempDir::new().unwrap();
313        fs::create_dir(tmp.path().join(".circleci")).unwrap();
314        assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::CircleCi));
315    }
316
317    #[test]
318    fn detect_ci_provider_finds_gitlab() {
319        let tmp = TempDir::new().unwrap();
320        fs::write(tmp.path().join(".gitlab-ci.yml"), "").unwrap();
321        assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitLab));
322    }
323
324    #[test]
325    fn detect_ci_provider_finds_buildkite() {
326        let tmp = TempDir::new().unwrap();
327        fs::create_dir(tmp.path().join(".buildkite")).unwrap();
328        assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::Buildkite));
329    }
330
331    #[test]
332    fn detect_ci_provider_returns_none_for_empty_dir() {
333        let tmp = TempDir::new().unwrap();
334        assert_eq!(detect_ci_provider(tmp.path()), None);
335    }
336
337    #[test]
338    fn install_github_workflow_creates_workflow_file() {
339        let tmp = TempDir::new().unwrap();
340
341        let result = install_github_workflow(tmp.path()).unwrap();
342        assert!(result.contains("Created GitHub Actions workflow"));
343
344        let workflow_path = tmp.path().join(".github/workflows/souk-validate.yml");
345        assert!(workflow_path.exists());
346
347        let content = fs::read_to_string(&workflow_path).unwrap();
348        assert!(content.contains("Souk Marketplace Validation"));
349        assert!(content.contains("souk validate marketplace"));
350        assert!(content.contains("actions/checkout@v4"));
351        assert!(content.contains("cargo install souk"));
352    }
353
354    #[test]
355    fn install_github_workflow_skips_if_exists() {
356        let tmp = TempDir::new().unwrap();
357        let workflows_dir = tmp.path().join(".github/workflows");
358        fs::create_dir_all(&workflows_dir).unwrap();
359        fs::write(
360            workflows_dir.join("souk-validate.yml"),
361            "name: existing\nrun: souk validate marketplace\n",
362        )
363        .unwrap();
364
365        let result = install_github_workflow(tmp.path()).unwrap();
366        assert!(result.contains("already exists"));
367    }
368
369    #[test]
370    fn install_circleci_config_creates_config_file() {
371        let tmp = TempDir::new().unwrap();
372
373        let result = install_circleci_config(tmp.path()).unwrap();
374        assert!(result.contains("Created CircleCI configuration"));
375
376        let config_path = tmp.path().join(".circleci/config.yml");
377        assert!(config_path.exists());
378
379        let content = fs::read_to_string(&config_path).unwrap();
380        assert!(content.contains("souk-validate"));
381        assert!(content.contains("cargo install souk"));
382        assert!(content.contains("souk validate marketplace"));
383    }
384
385    #[test]
386    fn install_circleci_config_appends_to_existing() {
387        let tmp = TempDir::new().unwrap();
388        let circleci_dir = tmp.path().join(".circleci");
389        fs::create_dir(&circleci_dir).unwrap();
390        fs::write(
391            circleci_dir.join("config.yml"),
392            "version: 2.1\njobs:\n  build:\n    steps: []\n",
393        )
394        .unwrap();
395
396        let result = install_circleci_config(tmp.path()).unwrap();
397        assert!(result.contains("Appended"));
398
399        let content = fs::read_to_string(circleci_dir.join("config.yml")).unwrap();
400        assert!(content.contains("version: 2.1"));
401        assert!(content.contains("Souk validation"));
402    }
403
404    #[test]
405    fn install_gitlab_config_creates_file() {
406        let tmp = TempDir::new().unwrap();
407
408        let result = install_gitlab_config(tmp.path()).unwrap();
409        assert!(result.contains("Created GitLab CI configuration"));
410
411        let config_path = tmp.path().join(".gitlab-ci.yml");
412        assert!(config_path.exists());
413
414        let content = fs::read_to_string(&config_path).unwrap();
415        assert!(content.contains("souk-validate"));
416        assert!(content.contains("souk validate marketplace"));
417    }
418
419    #[test]
420    fn install_gitlab_config_appends_to_existing() {
421        let tmp = TempDir::new().unwrap();
422        fs::write(
423            tmp.path().join(".gitlab-ci.yml"),
424            "stages:\n  - test\n\nbuild:\n  script: echo ok\n",
425        )
426        .unwrap();
427
428        let result = install_gitlab_config(tmp.path()).unwrap();
429        assert!(result.contains("Appended"));
430
431        let content = fs::read_to_string(tmp.path().join(".gitlab-ci.yml")).unwrap();
432        assert!(content.contains("stages:"));
433        assert!(content.contains("souk-validate"));
434    }
435
436    #[test]
437    fn install_buildkite_config_creates_pipeline() {
438        let tmp = TempDir::new().unwrap();
439
440        let result = install_buildkite_config(tmp.path()).unwrap();
441        assert!(result.contains("Created Buildkite pipeline"));
442
443        let pipeline_path = tmp.path().join(".buildkite/pipeline.yml");
444        assert!(pipeline_path.exists());
445
446        let content = fs::read_to_string(&pipeline_path).unwrap();
447        assert!(content.contains("Validate Marketplace"));
448        assert!(content.contains("souk validate marketplace"));
449    }
450
451    #[test]
452    fn ci_provider_name_returns_expected_values() {
453        assert_eq!(CiProvider::GitHub.name(), "github");
454        assert_eq!(CiProvider::Blacksmith.name(), "blacksmith");
455        assert_eq!(CiProvider::Northflank.name(), "northflank");
456        assert_eq!(CiProvider::CircleCi.name(), "circleci");
457        assert_eq!(CiProvider::GitLab.name(), "gitlab");
458        assert_eq!(CiProvider::Buildkite.name(), "buildkite");
459    }
460
461    #[test]
462    fn ci_provider_display() {
463        assert_eq!(format!("{}", CiProvider::GitHub), "github");
464        assert_eq!(format!("{}", CiProvider::CircleCi), "circleci");
465    }
466
467    #[test]
468    fn install_workflow_dispatches_to_github_for_compatible_providers() {
469        let tmp = TempDir::new().unwrap();
470
471        // All three should create the same GitHub workflow file
472        for provider in &[
473            CiProvider::GitHub,
474            CiProvider::Blacksmith,
475            CiProvider::Northflank,
476        ] {
477            let tmp_inner = TempDir::new().unwrap();
478            let result = install_workflow(tmp_inner.path(), provider).unwrap();
479            assert!(result.contains("GitHub Actions workflow"));
480
481            let workflow_path = tmp_inner.path().join(".github/workflows/souk-validate.yml");
482            assert!(workflow_path.exists());
483        }
484
485        // Clean up unused tmp binding
486        drop(tmp);
487    }
488
489    #[test]
490    fn detect_ci_provider_github_takes_priority_over_others() {
491        let tmp = TempDir::new().unwrap();
492        // Create both GitHub and GitLab configs
493        fs::create_dir_all(tmp.path().join(".github/workflows")).unwrap();
494        fs::write(tmp.path().join(".gitlab-ci.yml"), "").unwrap();
495
496        // GitHub should win since it's checked first
497        assert_eq!(detect_ci_provider(tmp.path()), Some(CiProvider::GitHub));
498    }
499}