ggen_cli_lib/cmds/shell/
init.rs

1//! Shell and project initialization utilities.
2//!
3//! # WHAT THIS MODULE SHOULD DO (Intent-Driven Architecture)
4//!
5//! ## PURPOSE
6//! This module should streamline the onboarding experience by automating
7//! shell integration, project setup, and development environment configuration,
8//! reducing friction for new users and ensuring consistent setups.
9//!
10//! ## RESPONSIBILITIES
11//! 1. **Shell Integration**: Should configure shell RC files for completions/aliases
12//! 2. **Project Init**: Should scaffold new projects from templates
13//! 3. **Dev Environment**: Should configure tools (git, editor, linters)
14//! 4. **Cross-Platform**: Should support bash, zsh, fish, powershell
15//! 5. **Safe Updates**: Should backup configs before modification
16//!
17//! ## CONSTRAINTS
18//! - Must detect shell type automatically when possible
19//! - Must validate config file paths before writing
20//! - Must support force flag for re-initialization
21//! - Must provide clear help messages for all options
22//! - Must preserve user's existing shell configurations
23//!
24//! ## DEPENDENCIES
25//! - Cargo make: Should delegate to makefile tasks
26//! - Initializer traits: Should be mockable for testing
27//! - Filesystem: Should safely modify config files
28//! - Templates: Should access project scaffolding templates
29//!
30//! ## ERROR HANDLING STRATEGY
31//! - Unsupported shell → List supported shells, suggest manual config
32//! - Config file missing → Offer to create it
33//! - Permission denied → Suggest running with appropriate permissions
34//! - Template not found → List available templates
35//! - Init already done → Suggest --force flag
36//!
37//! ## TESTING STRATEGY
38//! - Mock all three initializer traits separately
39//! - Test each shell type with mock configs
40//! - Test force flag behavior
41//! - Test error paths (missing files, permissions)
42//! - Test integration between subcommands
43//!
44//! ## REFACTORING PRIORITIES
45//! - [P0] Implement actual shell config modification (currently delegates to cargo make)
46//! - [P1] Add auto-detection of shell type
47//! - [P1] Create backup before modifying configs
48//! - [P1] Add project template selection UI
49//! - [P2] Support custom template repositories
50//!
51//! # Examples
52//!
53//! ```bash
54//! ggen shell init shell --shell zsh --force
55//! ggen shell init project --name "my-project" --template "rust-cli"
56//! ggen shell init dev --all
57//! ```
58//!
59//! # Errors
60//!
61//! Returns errors if initialization fails, configuration files can't be created,
62//! or if the specified shell or template is not supported.
63
64use clap::{Args, Subcommand};
65use ggen_utils::error::Result;
66// CLI output only - no library logging
67
68#[cfg_attr(test, mockall::automock)]
69pub trait ShellInitializer {
70    fn init_shell(&self, shell: &str, config: Option<String>, force: bool) -> Result<InitResult>;
71}
72
73#[cfg_attr(test, mockall::automock)]
74pub trait ProjectInitializer {
75    fn init_project(
76        &self, name: Option<String>, template: Option<String>, here: bool,
77    ) -> Result<InitResult>;
78}
79
80#[cfg_attr(test, mockall::automock)]
81pub trait DevInitializer {
82    fn init_dev(&self, all: bool) -> Result<InitResult>;
83}
84
85#[derive(Debug, Clone)]
86pub struct InitResult {
87    pub stdout: String,
88    pub stderr: String,
89    pub success: bool,
90}
91
92#[derive(Args, Debug)]
93pub struct InitArgs {
94    #[command(subcommand)]
95    pub action: InitAction,
96}
97
98#[derive(Subcommand, Debug)]
99pub enum InitAction {
100    /// Initialize shell integration
101    Shell(ShellInitArgs),
102
103    /// Initialize project configuration
104    Project(ProjectInitArgs),
105
106    /// Initialize development environment
107    Dev(DevInitArgs),
108}
109
110#[derive(Args, Debug)]
111pub struct ShellInitArgs {
112    /// Shell type (bash, zsh, fish, powershell)
113    #[arg(long, help = "Shell type: bash, zsh, fish, or powershell")]
114    pub shell: String,
115
116    /// Configuration file path [default: auto-detect]
117    #[arg(
118        long,
119        help = "Path to shell configuration file (auto-detected if not provided)"
120    )]
121    pub config: Option<String>,
122
123    /// Force initialization even if already configured
124    #[arg(long, help = "Force re-initialization even if already configured")]
125    pub force: bool,
126}
127
128#[derive(Args, Debug)]
129pub struct ProjectInitArgs {
130    /// Project name
131    #[arg(long)]
132    pub name: Option<String>,
133
134    /// Project template to use
135    #[arg(long)]
136    pub template: Option<String>,
137
138    /// Initialize in current directory
139    #[arg(long)]
140    pub here: bool,
141}
142
143#[derive(Args, Debug)]
144pub struct DevInitArgs {
145    /// Development tool to initialize
146    #[arg(long)]
147    pub tool: Option<String>,
148
149    /// Configuration file path
150    #[arg(long)]
151    pub config: Option<String>,
152}
153
154pub async fn run(args: &InitArgs) -> Result<()> {
155    let shell_init = CargoMakeShellInitializer;
156    let project_init = CargoMakeProjectInitializer;
157    let dev_init = CargoMakeDevInitializer;
158
159    run_with_deps(args, &shell_init, &project_init, &dev_init).await
160}
161
162pub async fn run_with_deps(
163    args: &InitArgs, shell_init: &dyn ShellInitializer, project_init: &dyn ProjectInitializer,
164    dev_init: &dyn DevInitializer,
165) -> Result<()> {
166    match &args.action {
167        InitAction::Shell(shell_args) => init_shell_with_deps(shell_args, shell_init).await,
168        InitAction::Project(project_args) => {
169            init_project_with_deps(project_args, project_init).await
170        }
171        InitAction::Dev(dev_args) => init_dev_with_deps(dev_args, dev_init).await,
172    }
173}
174
175async fn init_shell_with_deps(
176    args: &ShellInitArgs, shell_init: &dyn ShellInitializer,
177) -> Result<()> {
178    println!("Initializing shell integration for {}", args.shell);
179
180    let result = shell_init.init_shell(&args.shell, args.config.clone(), args.force)?;
181
182    if !result.success {
183        return Err(ggen_utils::error::Error::new_fmt(format_args!(
184            "Shell initialization failed: {}",
185            result.stderr
186        )));
187    }
188
189    println!("✅ Shell integration initialized successfully");
190    println!("{}", result.stdout);
191    Ok(())
192}
193
194#[allow(dead_code)]
195async fn init_shell(args: &ShellInitArgs) -> Result<()> {
196    let shell_init = CargoMakeShellInitializer;
197    init_shell_with_deps(args, &shell_init).await
198}
199
200async fn init_project_with_deps(
201    args: &ProjectInitArgs, project_init: &dyn ProjectInitializer,
202) -> Result<()> {
203    println!("Initializing project");
204
205    let result = project_init.init_project(args.name.clone(), args.template.clone(), args.here)?;
206
207    if !result.success {
208        return Err(ggen_utils::error::Error::new_fmt(format_args!(
209            "Project initialization failed: {}",
210            result.stderr
211        )));
212    }
213
214    println!("✅ Project initialized successfully");
215    println!("{}", result.stdout);
216    Ok(())
217}
218
219#[allow(dead_code)]
220async fn init_project(args: &ProjectInitArgs) -> Result<()> {
221    let project_init = CargoMakeProjectInitializer;
222    init_project_with_deps(args, &project_init).await
223}
224
225async fn init_dev_with_deps(_args: &DevInitArgs, dev_init: &dyn DevInitializer) -> Result<()> {
226    println!("Initializing development environment");
227
228    let result = dev_init.init_dev(false)?;
229
230    if !result.success {
231        return Err(ggen_utils::error::Error::new_fmt(format_args!(
232            "Development environment initialization failed: {}",
233            result.stderr
234        )));
235    }
236
237    println!("✅ Development environment initialized successfully");
238    println!("{}", result.stdout);
239    Ok(())
240}
241
242#[allow(dead_code)]
243async fn init_dev(args: &DevInitArgs) -> Result<()> {
244    let dev_init = CargoMakeDevInitializer;
245    init_dev_with_deps(args, &dev_init).await
246}
247
248// Concrete implementations for production use
249pub struct CargoMakeShellInitializer;
250
251impl ShellInitializer for CargoMakeShellInitializer {
252    fn init_shell(&self, shell: &str, config: Option<String>, force: bool) -> Result<InitResult> {
253        let mut cmd = std::process::Command::new("cargo");
254        cmd.args(["make", "shell-init"]);
255
256        cmd.arg("--shell").arg(shell);
257
258        if let Some(config) = config {
259            cmd.arg("--config").arg(config);
260        }
261
262        if force {
263            cmd.arg("--force");
264        }
265
266        let output = cmd.output()?;
267        Ok(InitResult {
268            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
269            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
270            success: output.status.success(),
271        })
272    }
273}
274
275pub struct CargoMakeProjectInitializer;
276
277impl ProjectInitializer for CargoMakeProjectInitializer {
278    fn init_project(
279        &self, name: Option<String>, template: Option<String>, here: bool,
280    ) -> Result<InitResult> {
281        let mut cmd = std::process::Command::new("cargo");
282        cmd.args(["make", "project-init"]);
283
284        if let Some(name) = name {
285            cmd.arg("--name").arg(name);
286        }
287
288        if let Some(template) = template {
289            cmd.arg("--template").arg(template);
290        }
291
292        if here {
293            cmd.arg("--here");
294        }
295
296        let output = cmd.output()?;
297        Ok(InitResult {
298            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
299            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
300            success: output.status.success(),
301        })
302    }
303}
304
305pub struct CargoMakeDevInitializer;
306
307impl DevInitializer for CargoMakeDevInitializer {
308    fn init_dev(&self, all: bool) -> Result<InitResult> {
309        let mut cmd = std::process::Command::new("cargo");
310        cmd.args(["make", "dev-init"]);
311
312        if all {
313            cmd.arg("--all");
314        }
315
316        let output = cmd.output()?;
317        Ok(InitResult {
318            stdout: String::from_utf8_lossy(&output.stdout).to_string(),
319            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
320            success: output.status.success(),
321        })
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use mockall::predicate::*;
329
330    #[tokio::test]
331    async fn test_init_shell_calls_initializer() {
332        let mut mock = MockShellInitializer::new();
333        mock.expect_init_shell()
334            .with(eq("zsh"), eq(None::<String>), eq(false))
335            .times(1)
336            .returning(|_, _, _| {
337                Ok(InitResult {
338                    stdout: "Shell initialized".to_string(),
339                    stderr: "".to_string(),
340                    success: true,
341                })
342            });
343
344        let args = ShellInitArgs {
345            shell: "zsh".to_string(),
346            config: None,
347            force: false,
348        };
349        let result = init_shell_with_deps(&args, &mock).await;
350        assert!(result.is_ok());
351    }
352
353    #[tokio::test]
354    async fn test_init_shell_with_config() {
355        let mut mock = MockShellInitializer::new();
356        mock.expect_init_shell()
357            .with(eq("bash"), eq(Some("/tmp/bashrc".to_string())), eq(true))
358            .times(1)
359            .returning(|_, _, _| {
360                Ok(InitResult {
361                    stdout: "Shell initialized".to_string(),
362                    stderr: "".to_string(),
363                    success: true,
364                })
365            });
366
367        let args = ShellInitArgs {
368            shell: "bash".to_string(),
369            config: Some("/tmp/bashrc".to_string()),
370            force: true,
371        };
372        let result = init_shell_with_deps(&args, &mock).await;
373        assert!(result.is_ok());
374    }
375
376    #[tokio::test]
377    async fn test_init_project_calls_initializer() {
378        let mut mock = MockProjectInitializer::new();
379        mock.expect_init_project()
380            .with(
381                eq(Some("my-project".to_string())),
382                eq(Some("rust-cli".to_string())),
383                eq(false),
384            )
385            .times(1)
386            .returning(|_, _, _| {
387                Ok(InitResult {
388                    stdout: "Project initialized".to_string(),
389                    stderr: "".to_string(),
390                    success: true,
391                })
392            });
393
394        let args = ProjectInitArgs {
395            name: Some("my-project".to_string()),
396            template: Some("rust-cli".to_string()),
397            here: false,
398        };
399        let result = init_project_with_deps(&args, &mock).await;
400        assert!(result.is_ok());
401    }
402
403    #[tokio::test]
404    async fn test_init_dev_calls_initializer() {
405        let mut mock = MockDevInitializer::new();
406        mock.expect_init_dev()
407            .with(eq(false))
408            .times(1)
409            .returning(|_| {
410                Ok(InitResult {
411                    stdout: "Dev environment initialized".to_string(),
412                    stderr: "".to_string(),
413                    success: true,
414                })
415            });
416
417        let args = DevInitArgs {
418            tool: None,
419            config: None,
420        };
421        let result = init_dev_with_deps(&args, &mock).await;
422        assert!(result.is_ok());
423    }
424
425    #[tokio::test]
426    async fn test_run_with_deps_dispatches_correctly() {
427        let mut mock_shell_init = MockShellInitializer::new();
428        mock_shell_init
429            .expect_init_shell()
430            .with(eq("fish"), eq(None::<String>), eq(false))
431            .times(1)
432            .returning(|_, _, _| {
433                Ok(InitResult {
434                    stdout: "Shell initialized".to_string(),
435                    stderr: "".to_string(),
436                    success: true,
437                })
438            });
439
440        let mock_project_init = MockProjectInitializer::new();
441        let mock_dev_init = MockDevInitializer::new();
442
443        let args = InitArgs {
444            action: InitAction::Shell(ShellInitArgs {
445                shell: "fish".to_string(),
446                config: None,
447                force: false,
448            }),
449        };
450
451        let result =
452            run_with_deps(&args, &mock_shell_init, &mock_project_init, &mock_dev_init).await;
453        assert!(result.is_ok());
454    }
455
456    #[test]
457    fn test_shell_init_args_defaults() {
458        let args = ShellInitArgs {
459            shell: "zsh".to_string(),
460            config: None,
461            force: false,
462        };
463        assert_eq!(args.shell, "zsh");
464        assert!(args.config.is_none());
465        assert!(!args.force);
466    }
467
468    #[test]
469    fn test_project_init_args_defaults() {
470        let args = ProjectInitArgs {
471            name: None,
472            template: None,
473            here: false,
474        };
475        assert!(args.name.is_none());
476        assert!(args.template.is_none());
477        assert!(!args.here);
478    }
479
480    #[test]
481    fn test_dev_init_args_defaults() {
482        let args = DevInitArgs {
483            tool: None,
484            config: None,
485        };
486        assert!(args.tool.is_none());
487        assert!(args.config.is_none());
488    }
489}