diff --git a/src/cli_args.rs b/src/cli_args.rs index 9edb04f..4bca89b 100644 --- a/src/cli_args.rs +++ b/src/cli_args.rs @@ -67,6 +67,14 @@ pub enum Command { subcommand: ProjectSubcommand, }, + /// Opens a new shell in the puff data directory where managed files are stored. + /// Use --print to just print the path instead. + Cd { + /// Print the path instead of spawning a shell + #[arg(short = 'p', long = "print")] + print: bool, + }, + /// Generates shell completions for the given shell and prints them to stdout #[command(after_help = "\ Examples: diff --git a/src/commands.rs b/src/commands.rs index 04c3cd4..3669233 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,5 @@ pub mod add_command; +pub mod cd_command; pub mod file_forget_command; pub mod init_command; pub mod list_command; diff --git a/src/commands/cd_command.rs b/src/commands/cd_command.rs new file mode 100644 index 0000000..8de0b53 --- /dev/null +++ b/src/commands/cd_command.rs @@ -0,0 +1,53 @@ +use anyhow::{anyhow, Result}; +use std::fs; +use std::process::Command; + +use crate::config::locations::LocationsProvider; + +pub struct CdCommand<'a> { + locations_provider: &'a LocationsProvider, +} + +impl<'a> CdCommand<'a> { + pub fn new(locations_provider: &'a LocationsProvider) -> Self { + CdCommand { + locations_provider, + } + } + + pub fn cd(&self, print: bool) -> Result<()> { + let path = self.locations_provider.get_projects_data_path(); + + if !path.exists() { + fs::create_dir_all(&path)?; + } + + if print { + println!("{}", path.display()); + return Ok(()); + } + + let shell = get_shell()?; + let config_path = self.locations_provider.get_base_config_path()?; + let data_path = self.locations_provider.get_base_data_path()?; + let status = Command::new(&shell) + .current_dir(&path) + .env("PUFF_SUBSHELL", "1") + .env("PUFF_CONFIG_PATH", &config_path) + .env("PUFF_DATA_PATH", &data_path) + .status() + .map_err(|e| anyhow!("Failed to spawn shell '{}': {}", shell, e))?; + + std::process::exit(status.code().unwrap_or(1)); + } +} + +#[cfg(unix)] +fn get_shell() -> Result { + std::env::var("SHELL").or_else(|_| Ok("/bin/sh".to_string())) +} + +#[cfg(windows)] +fn get_shell() -> Result { + std::env::var("COMSPEC").or_else(|_| Ok("cmd.exe".to_string())) +} diff --git a/src/main.rs b/src/main.rs index dd139bf..08ea001 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,9 @@ use clap::{CommandFactory, Parser}; use clap_complete::generate; use cli_args::{AppArgs, Command}; use commands::{ - add_command::AddCommand, file_forget_command::ForgetCommand, init_command::InitCommand, - list_command::ListCommand, project_forget_command::ProjectForgetCommand, - status_command::StatusCommand, + add_command::AddCommand, cd_command::CdCommand, file_forget_command::ForgetCommand, + init_command::InitCommand, list_command::ListCommand, + project_forget_command::ProjectForgetCommand, status_command::StatusCommand, }; use config::{ app_config::AppConfigManager, locations::LocationsProvider, projects::ProjectsRetriever, @@ -62,6 +62,12 @@ fn run() -> Result<()> { } }; + if let Command::Cd { print } = args.command { + let cd = CdCommand::new(&locations_provider); + cd.cd(print)?; + return Ok(()); + } + AppInitializer { locations_provider: &locations_provider, } @@ -135,7 +141,7 @@ fn run() -> Result<()> { } }, // handled up above - Command::Completions { .. } => unreachable!(), + Command::Completions { .. } | Command::Cd { .. } => unreachable!(), } Ok(()) diff --git a/tests/e2e/cd.bats b/tests/e2e/cd.bats new file mode 100644 index 0000000..f87f4ef --- /dev/null +++ b/tests/e2e/cd.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +load helpers + +setup() { setup_puff_env; } +teardown() { teardown_puff_env; } + +@test "cd --print: prints the projects data path" { + run puff cd --print + assert_success + output="$(echo "$output" | normalize_output)" + assert_output_contains "$(native_path "$PUFF_DATA_PATH")" + assert_output_contains "projects" +} + +@test "cd -p: short flag works" { + run puff cd -p + assert_success + output="$(echo "$output" | normalize_output)" + assert_output_contains "$(native_path "$PUFF_DATA_PATH")" +} + +@test "cd: spawns shell in projects directory" { + if is_windows; then + local fake_shell="$PUFF_CONFIG_PATH/fake_shell.bat" + echo '@cd' >"$fake_shell" + export COMSPEC="$(native_path "$fake_shell")" + else + local fake_shell="$PUFF_CONFIG_PATH/fake_shell" + printf '#!/usr/bin/env bash\npwd\n' >"$fake_shell" + chmod +x "$fake_shell" + export SHELL="$fake_shell" + fi + + run puff cd + assert_success + output="$(echo "$output" | normalize_output)" + assert_output_contains "$(native_path "$PUFF_DATA_PATH")" +} + +@test "cd: sets PUFF_SUBSHELL env var" { + if is_windows; then + local fake_shell="$PUFF_CONFIG_PATH/fake_shell.bat" + echo '@echo PUFF_SUBSHELL=%PUFF_SUBSHELL%' >"$fake_shell" + export COMSPEC="$(native_path "$fake_shell")" + else + local fake_shell="$PUFF_CONFIG_PATH/fake_shell" + printf '#!/usr/bin/env bash\necho "PUFF_SUBSHELL=$PUFF_SUBSHELL"\n' >"$fake_shell" + chmod +x "$fake_shell" + export SHELL="$fake_shell" + fi + + run puff cd + assert_success + output="$(echo "$output" | normalize_output)" + assert_output_contains "PUFF_SUBSHELL=1" +} + +@test "cd: exports PUFF_CONFIG_PATH and PUFF_DATA_PATH" { + if is_windows; then + local fake_shell="$PUFF_CONFIG_PATH/fake_shell.bat" + cat >"$fake_shell" <<'BATCH' +@echo config=%PUFF_CONFIG_PATH% +@echo data=%PUFF_DATA_PATH% +BATCH + export COMSPEC="$(native_path "$fake_shell")" + else + local fake_shell="$PUFF_CONFIG_PATH/fake_shell" + printf '#!/usr/bin/env bash\necho "config=$PUFF_CONFIG_PATH"\necho "data=$PUFF_DATA_PATH"\n' >"$fake_shell" + chmod +x "$fake_shell" + export SHELL="$fake_shell" + fi + + run puff cd + assert_success + output="$(echo "$output" | normalize_output)" + assert_output_contains "config=$(native_path "$PUFF_CONFIG_PATH")" + assert_output_contains "data=$(native_path "$PUFF_DATA_PATH")" +} diff --git a/tests/e2e/helpers.bash b/tests/e2e/helpers.bash index 4e6ea22..6d7ff59 100644 --- a/tests/e2e/helpers.bash +++ b/tests/e2e/helpers.bash @@ -93,6 +93,16 @@ assert_output_contains() { fi } +is_windows() { + command -v cygpath &>/dev/null +} + +# Normalizes output for cross-platform path comparison: +# converts backslashes to forward slashes and strips carriage returns. +normalize_output() { + tr '\\' '/' | tr -d '\r' +} + assert_output_not_contains() { if echo "$output" | grep -qF "$1"; then echo "Expected output to NOT contain: $1" >&2