Skip to content

Commit ea87dc6

Browse files
committed
archive cmd
1 parent a6b8ebd commit ea87dc6

File tree

5 files changed

+550
-8
lines changed

5 files changed

+550
-8
lines changed

Cargo.lock

Lines changed: 106 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dirs = "5.0"
1717
regex = "1.10"
1818
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
1919
tokio = { version = "1.43", features = ["rt-multi-thread", "macros", "process", "io-util", "fs", "signal"] }
20+
chrono = "0.4"
2021

2122
[dev-dependencies]
2223
assert_cmd = "2.1.2"

src/files.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ pub const LOG_FILE: &str = "ralph.log";
1515
/// All ralph files that can be created/cleaned.
1616
pub const RALPH_FILES: &[&str] = &[SPEC_FILE, IMPLEMENTATION_PLAN_FILE, PROMPT_FILE, LOG_FILE];
1717

18+
/// Files that are archived (stateful files, not templates or logs).
19+
pub const ARCHIVABLE_FILES: &[&str] = &[SPEC_FILE, IMPLEMENTATION_PLAN_FILE];
20+
21+
/// The ralphctl directory for storing archives and other data.
22+
pub const RALPHCTL_DIR: &str = ".ralphctl";
23+
24+
/// The archive subdirectory within .ralphctl.
25+
pub const ARCHIVE_DIR: &str = "archive";
26+
1827
/// Find all ralph files that exist in the given directory.
1928
///
2029
/// Returns a list of paths to existing ralph files.
@@ -31,6 +40,22 @@ pub fn any_ralph_files_exist(dir: &Path) -> bool {
3140
RALPH_FILES.iter().any(|name| dir.join(name).exists())
3241
}
3342

43+
/// Find archivable files that exist in the given directory.
44+
///
45+
/// Returns a list of paths to existing archivable files.
46+
pub fn find_archivable_files(dir: &Path) -> Vec<PathBuf> {
47+
ARCHIVABLE_FILES
48+
.iter()
49+
.map(|name| dir.join(name))
50+
.filter(|path| path.exists())
51+
.collect()
52+
}
53+
54+
/// Get the base archive directory path (.ralphctl/archive).
55+
pub fn archive_base_dir(dir: &Path) -> PathBuf {
56+
dir.join(RALPHCTL_DIR).join(ARCHIVE_DIR)
57+
}
58+
3459
#[cfg(test)]
3560
mod tests {
3661
use super::*;
@@ -97,4 +122,46 @@ mod tests {
97122
assert!(RALPH_FILES.contains(&LOG_FILE));
98123
assert_eq!(RALPH_FILES.len(), 4);
99124
}
125+
126+
#[test]
127+
fn test_archivable_files_constant() {
128+
assert!(ARCHIVABLE_FILES.contains(&SPEC_FILE));
129+
assert!(ARCHIVABLE_FILES.contains(&IMPLEMENTATION_PLAN_FILE));
130+
assert_eq!(ARCHIVABLE_FILES.len(), 2);
131+
// PROMPT.md and ralph.log are NOT archivable
132+
assert!(!ARCHIVABLE_FILES.contains(&PROMPT_FILE));
133+
assert!(!ARCHIVABLE_FILES.contains(&LOG_FILE));
134+
}
135+
136+
#[test]
137+
fn test_find_archivable_files_empty() {
138+
let dir = create_temp_dir();
139+
let found = find_archivable_files(dir.path());
140+
assert!(found.is_empty());
141+
}
142+
143+
#[test]
144+
fn test_find_archivable_files_only_archivable() {
145+
let dir = create_temp_dir();
146+
147+
// Create archivable files
148+
fs::write(dir.path().join(SPEC_FILE), "# Spec").unwrap();
149+
fs::write(dir.path().join(IMPLEMENTATION_PLAN_FILE), "# Plan").unwrap();
150+
// Create non-archivable file
151+
fs::write(dir.path().join(PROMPT_FILE), "# Prompt").unwrap();
152+
153+
let found = find_archivable_files(dir.path());
154+
assert_eq!(found.len(), 2);
155+
assert!(found.iter().any(|p| p.ends_with(SPEC_FILE)));
156+
assert!(found.iter().any(|p| p.ends_with(IMPLEMENTATION_PLAN_FILE)));
157+
// PROMPT.md should not be in the list
158+
assert!(!found.iter().any(|p| p.ends_with(PROMPT_FILE)));
159+
}
160+
161+
#[test]
162+
fn test_archive_base_dir() {
163+
let dir = create_temp_dir();
164+
let archive_dir = archive_base_dir(dir.path());
165+
assert!(archive_dir.ends_with(".ralphctl/archive"));
166+
}
100167
}

src/main.rs

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ enum Command {
6767
#[arg(long)]
6868
force: bool,
6969
},
70+
71+
/// Archive current SPEC.md and IMPLEMENTATION_PLAN.md, then reset to blank
72+
Archive {
73+
/// Skip confirmation prompt
74+
#[arg(long)]
75+
force: bool,
76+
},
7077
}
7178

7279
#[tokio::main]
@@ -93,6 +100,9 @@ async fn main() -> Result<()> {
93100
Command::Clean { force } => {
94101
clean_cmd(force)?;
95102
}
103+
Command::Archive { force } => {
104+
archive_cmd(force)?;
105+
}
96106
}
97107

98108
Ok(())
@@ -149,6 +159,105 @@ fn clean_cmd(force: bool) -> Result<()> {
149159
Ok(())
150160
}
151161

162+
fn archive_cmd(force: bool) -> Result<()> {
163+
let cwd = Path::new(".");
164+
let archivable_files = files::find_archivable_files(cwd);
165+
166+
if archivable_files.is_empty() {
167+
println!("No archivable files found.");
168+
return Ok(());
169+
}
170+
171+
let file_count = archivable_files.len();
172+
173+
if !force {
174+
eprint!(
175+
"Archive {} file{}? [y/N] ",
176+
file_count,
177+
if file_count == 1 { "" } else { "s" }
178+
);
179+
io::stderr().flush()?;
180+
181+
let mut input = String::new();
182+
io::stdin().read_line(&mut input)?;
183+
184+
let answer = input.trim().to_lowercase();
185+
if answer != "y" && answer != "yes" {
186+
std::process::exit(error::exit::ERROR);
187+
}
188+
}
189+
190+
// Ensure .ralphctl is in .gitignore
191+
update_gitignore(cwd)?;
192+
193+
// Create timestamped archive directory
194+
let timestamp = generate_timestamp();
195+
let archive_dir = files::archive_base_dir(cwd).join(&timestamp);
196+
fs::create_dir_all(&archive_dir)?;
197+
198+
// Copy files to archive
199+
for path in &archivable_files {
200+
let filename = path.file_name().unwrap();
201+
let dest = archive_dir.join(filename);
202+
fs::copy(path, dest)?;
203+
}
204+
205+
// Reset original files to blank templates
206+
for path in &archivable_files {
207+
let blank = generate_blank_content(path);
208+
fs::write(path, blank)?;
209+
}
210+
211+
println!(
212+
"Archived {} file{} to {}",
213+
file_count,
214+
if file_count == 1 { "" } else { "s" },
215+
archive_dir.display()
216+
);
217+
218+
Ok(())
219+
}
220+
221+
/// Generate a filesystem-safe timestamp for archive directories.
222+
fn generate_timestamp() -> String {
223+
chrono::Local::now().format("%Y-%m-%dT%H-%M-%S").to_string()
224+
}
225+
226+
/// Generate blank content for a given file.
227+
fn generate_blank_content(path: &Path) -> &'static str {
228+
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
229+
match filename {
230+
files::SPEC_FILE => "# Specification\n\n",
231+
files::IMPLEMENTATION_PLAN_FILE => "# Implementation Plan\n\n",
232+
_ => "",
233+
}
234+
}
235+
236+
/// Update .gitignore to include .ralphctl if not already present.
237+
fn update_gitignore(dir: &Path) -> Result<()> {
238+
let gitignore_path = dir.join(".gitignore");
239+
let entry = files::RALPHCTL_DIR;
240+
241+
if gitignore_path.exists() {
242+
let content = fs::read_to_string(&gitignore_path)?;
243+
// Check if entry already exists (as a complete line)
244+
if content.lines().any(|line| line.trim() == entry) {
245+
return Ok(());
246+
}
247+
// Append entry with newline handling
248+
let suffix = if content.ends_with('\n') || content.is_empty() {
249+
format!("{}\n", entry)
250+
} else {
251+
format!("\n{}\n", entry)
252+
};
253+
fs::write(&gitignore_path, content + &suffix)?;
254+
} else {
255+
fs::write(&gitignore_path, format!("{}\n", entry))?;
256+
}
257+
258+
Ok(())
259+
}
260+
152261
fn run_cmd(max_iterations: u32, pause: bool, model: Option<&str>) -> Result<()> {
153262
// Step 1: Validate required files exist
154263
run::validate_required_files()?;
@@ -351,14 +460,11 @@ NEVER use paths from other context (like ~/.claude/CLAUDE.md). The path above is
351460
cmd.arg("--model").arg(m);
352461
}
353462

354-
let status = cmd
355-
.arg(INITIAL_PROMPT)
356-
.status()
357-
.inspect_err(|e| {
358-
if e.kind() == std::io::ErrorKind::NotFound {
359-
error::die("claude not found in PATH");
360-
}
361-
})?;
463+
let status = cmd.arg(INITIAL_PROMPT).status().inspect_err(|e| {
464+
if e.kind() == std::io::ErrorKind::NotFound {
465+
error::die("claude not found in PATH");
466+
}
467+
})?;
362468

363469
if !status.success() {
364470
error::die(&format!(

0 commit comments

Comments
 (0)