Skip to main content

claude_wrapper/command/
project.rs

1//! `claude project` subcommand wrappers.
2//!
3//! Currently exposes [`ProjectPurgeCommand`] for `claude project
4//! purge`, which deletes all Claude Code state for a project --
5//! transcripts, tasks, file history, and config entry. Destructive;
6//! callers running headless should always pass
7//! [`ProjectPurgeCommand::yes`] to skip the confirmation prompt
8//! (the CLI hangs on the prompt when stdin / stdout is not a TTY).
9
10#[cfg(feature = "async")]
11use crate::Claude;
12use crate::command::ClaudeCommand;
13#[cfg(feature = "async")]
14use crate::error::Result;
15#[cfg(feature = "async")]
16use crate::exec;
17use crate::exec::CommandOutput;
18
19/// Delete all Claude Code state for a project.
20///
21/// Wraps `claude project purge [path]`. Removes transcripts, tasks,
22/// file history, and the project's config entry. Use [`Self::path`]
23/// to target a specific project, or [`Self::all`] to purge every
24/// known project (the two are mutually exclusive at the CLI level;
25/// passing both lets the CLI decide).
26///
27/// **Headless callers should pass [`Self::yes`]** -- the CLI
28/// requires `-y` whenever stdin/stdout isn't a TTY and otherwise
29/// waits on a confirmation prompt that no one is around to answer.
30/// Combine with [`Self::dry_run`] to preview without deleting.
31///
32/// # Example
33///
34/// ```no_run
35/// use claude_wrapper::{Claude, ClaudeCommand, ProjectPurgeCommand};
36///
37/// # async fn example() -> claude_wrapper::Result<()> {
38/// let claude = Claude::builder().build()?;
39/// // Dry-run, no confirmation needed since nothing changes:
40/// let preview = ProjectPurgeCommand::new()
41///     .path("/some/project/path")
42///     .dry_run()
43///     .execute(&claude)
44///     .await?;
45/// println!("{}", preview.stdout);
46/// # Ok(()) }
47/// ```
48#[derive(Debug, Clone, Default)]
49pub struct ProjectPurgeCommand {
50    path: Option<String>,
51    all: bool,
52    dry_run: bool,
53    interactive: bool,
54    yes: bool,
55}
56
57impl ProjectPurgeCommand {
58    /// Create a new purge command. Without [`Self::path`] or
59    /// [`Self::all`], the CLI defaults to the current directory.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Purge state for the project at this path (positional `[path]`).
66    /// Mutually exclusive with [`Self::all`] at the CLI level.
67    #[must_use]
68    pub fn path(mut self, path: impl Into<String>) -> Self {
69        self.path = Some(path.into());
70        self
71    }
72
73    /// Purge state for every known project (`--all`). Mutually
74    /// exclusive with [`Self::path`] at the CLI level.
75    #[must_use]
76    pub fn all(mut self) -> Self {
77        self.all = true;
78        self
79    }
80
81    /// List what would be deleted without deleting anything
82    /// (`--dry-run`). Safe to combine with [`Self::all`] for a
83    /// preview of full-purge scope.
84    #[must_use]
85    pub fn dry_run(mut self) -> Self {
86        self.dry_run = true;
87        self
88    }
89
90    /// Prompt for each item before deleting (`-i, --interactive`).
91    /// Only useful in TTY contexts; headless callers should leave
92    /// this off and pair [`Self::dry_run`] with [`Self::yes`] for
93    /// scoped automation.
94    #[must_use]
95    pub fn interactive(mut self) -> Self {
96        self.interactive = true;
97        self
98    }
99
100    /// Skip the confirmation prompt (`-y`). **Required for non-TTY
101    /// callers** -- without it the CLI will hang waiting on stdin.
102    /// Every wrapper consumer running under `execute()` is non-TTY
103    /// by definition.
104    #[must_use]
105    pub fn yes(mut self) -> Self {
106        self.yes = true;
107        self
108    }
109}
110
111impl ClaudeCommand for ProjectPurgeCommand {
112    type Output = CommandOutput;
113
114    fn args(&self) -> Vec<String> {
115        let mut args = vec!["project".to_string(), "purge".to_string()];
116        if self.all {
117            args.push("--all".to_string());
118        }
119        if self.dry_run {
120            args.push("--dry-run".to_string());
121        }
122        if self.interactive {
123            args.push("--interactive".to_string());
124        }
125        if self.yes {
126            args.push("--yes".to_string());
127        }
128        if let Some(ref path) = self.path {
129            args.push(path.clone());
130        }
131        args
132    }
133
134    #[cfg(feature = "async")]
135    async fn execute(&self, claude: &Claude) -> Result<CommandOutput> {
136        exec::run_claude(claude, self.args()).await
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn purge_defaults_to_bare_subcommand() {
146        let cmd = ProjectPurgeCommand::new();
147        assert_eq!(ClaudeCommand::args(&cmd), vec!["project", "purge"]);
148    }
149
150    #[test]
151    fn purge_with_path_passes_positional() {
152        let cmd = ProjectPurgeCommand::new().path("/tmp/old-project");
153        assert_eq!(
154            ClaudeCommand::args(&cmd),
155            vec!["project", "purge", "/tmp/old-project"]
156        );
157    }
158
159    #[test]
160    fn purge_all_emits_flag() {
161        let cmd = ProjectPurgeCommand::new().all().yes();
162        assert_eq!(
163            ClaudeCommand::args(&cmd),
164            vec!["project", "purge", "--all", "--yes"]
165        );
166    }
167
168    #[test]
169    fn purge_dry_run_with_yes_is_safe_preview() {
170        let cmd = ProjectPurgeCommand::new().dry_run().yes();
171        assert_eq!(
172            ClaudeCommand::args(&cmd),
173            vec!["project", "purge", "--dry-run", "--yes"]
174        );
175    }
176
177    #[test]
178    fn purge_all_flags() {
179        let cmd = ProjectPurgeCommand::new()
180            .path("/proj")
181            .all()
182            .dry_run()
183            .interactive()
184            .yes();
185        // CLI accepts both --all and [path]; we emit them in
186        // canonical order and let the CLI decide.
187        assert_eq!(
188            ClaudeCommand::args(&cmd),
189            vec![
190                "project",
191                "purge",
192                "--all",
193                "--dry-run",
194                "--interactive",
195                "--yes",
196                "/proj"
197            ]
198        );
199    }
200
201    #[test]
202    fn purge_path_lands_last_so_positional_is_unambiguous() {
203        let cmd = ProjectPurgeCommand::new().yes().path("./me");
204        let args = ClaudeCommand::args(&cmd);
205        // The positional must be the final arg; anything else risks
206        // being interpreted as the path.
207        assert_eq!(args.last().map(String::as_str), Some("./me"));
208    }
209}