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}