Skip to content

Commit 40ea73c

Browse files
committed
[gobby-cli-#1010] feat: add wiki purge commands
1 parent 9645164 commit 40ea73c

22 files changed

Lines changed: 745 additions & 5 deletions

File tree

crates/gcode/src/cli.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,28 @@ pub(crate) enum Command {
376376
/// Output directory for generated Markdown docs
377377
#[arg(long)]
378378
out: Option<String>,
379+
/// Remove generated CodeWiki output/cache under --out and exit.
380+
#[arg(
381+
long,
382+
conflicts_with_all = [
383+
"scope",
384+
"ai",
385+
"ai_depth",
386+
"ai_aggregate_profile",
387+
"ai_verify_profile",
388+
"ai_verify_scope",
389+
"ai_prose_depth",
390+
"ai_register",
391+
"edge_limit",
392+
"include_docs",
393+
"since",
394+
"repair_citations",
395+
]
396+
)]
397+
purge: bool,
398+
/// Confirm destructive CodeWiki output purge.
399+
#[arg(long, requires = "purge")]
400+
force: bool,
379401
/// Limit docs to indexed files under one or more paths
380402
#[arg(long, num_args = 1.., value_name = "PATH")]
381403
scope: Vec<String>,

crates/gcode/src/cli/tests/codewiki.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,43 @@ fn parse_codewiki_repair_citations_flag() {
132132
}
133133
}
134134

135+
#[test]
136+
fn parse_codewiki_purge_flag() {
137+
let cli = Cli::try_parse_from(["gcode", "codewiki"]).expect("codewiki parses");
138+
match cli.command {
139+
Command::Codewiki { purge, force, .. } => {
140+
assert!(!purge, "purge defaults off");
141+
assert!(!force, "force defaults off");
142+
}
143+
_ => panic!("expected codewiki command"),
144+
}
145+
146+
let cli = Cli::try_parse_from([
147+
"gcode",
148+
"codewiki",
149+
"--purge",
150+
"--out",
151+
"gobby-wiki",
152+
"--force",
153+
])
154+
.expect("codewiki --purge parses");
155+
match cli.command {
156+
Command::Codewiki {
157+
out, purge, force, ..
158+
} => {
159+
assert_eq!(out.as_deref(), Some("gobby-wiki"));
160+
assert!(purge);
161+
assert!(force);
162+
}
163+
_ => panic!("expected codewiki command"),
164+
}
165+
166+
assert!(Cli::try_parse_from(["gcode", "codewiki", "--force"]).is_err());
167+
assert!(Cli::try_parse_from(["gcode", "codewiki", "--purge", "--scope", "src"]).is_err());
168+
assert!(Cli::try_parse_from(["gcode", "codewiki", "--purge", "--ai", "off"]).is_err());
169+
assert!(Cli::try_parse_from(["gcode", "codewiki", "--purge", "--repair-citations"]).is_err());
170+
}
171+
135172
#[test]
136173
fn parse_setup_standalone() {
137174
let cli = Cli::try_parse_from([

crates/gcode/src/commands/codewiki/io.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ fn ai_body_note(outcome: CodewikiAiOutcome) -> Option<&'static str> {
590590
/// `code/` so the rest of the vault — the gwiki research notes, `.obsidian/`,
591591
/// `_meta/` — is never walked. Symlinks are not followed and never returned,
592592
/// matching `reject_symlinked_doc_path`.
593-
fn collect_generated_doc_pages(out_dir: &Path) -> anyhow::Result<Vec<String>> {
593+
pub(crate) fn collect_generated_doc_pages(out_dir: &Path) -> anyhow::Result<Vec<String>> {
594594
let code_root = out_dir.join("code");
595595
if !code_root.is_dir() {
596596
return Ok(Vec::new());

crates/gcode/src/commands/codewiki/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ mod ownership;
128128
mod paths;
129129
mod progress;
130130
mod prompts;
131+
mod purge;
131132
mod relationship_facts;
132133
mod render;
133134
mod repair;
@@ -197,6 +198,9 @@ pub(crate) use render::{
197198
render_infrastructure_doc, render_module_doc, render_onboarding_doc,
198199
};
199200
// Reuse of unchanged docs without regeneration.
201+
#[cfg(test)]
202+
pub(crate) use purge::purge_generated_output;
203+
pub use purge::{CodewikiPurgeSummary, run_purge};
200204
pub(crate) use reuse::{ReusePlan, span_files};
201205
#[cfg(test)]
202206
pub(crate) use run::{
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use std::collections::BTreeSet;
2+
use std::path::Path;
3+
4+
use serde::Serialize;
5+
6+
use crate::config::Context;
7+
use crate::output::{self, Format};
8+
9+
use super::io::{
10+
collect_generated_doc_pages, prune_empty_doc_dirs, read_codewiki_meta,
11+
reject_symlinked_doc_path, safe_doc_path,
12+
};
13+
use super::truth_digest::TRUTH_DIGEST_META_PATH;
14+
use super::{CODEWIKI_META_PATH, DEFAULT_OUT_DIR, OWNERSHIP_META_PATH};
15+
16+
#[derive(Debug, Serialize)]
17+
pub struct CodewikiPurgeSummary {
18+
pub command: &'static str,
19+
pub project_id: String,
20+
pub project_root: String,
21+
pub out_dir: String,
22+
pub markdown_removed: usize,
23+
pub metadata_removed: usize,
24+
}
25+
26+
#[derive(Debug, Default, PartialEq, Eq)]
27+
pub(crate) struct CodewikiPurgeCounts {
28+
pub(crate) markdown_removed: usize,
29+
pub(crate) metadata_removed: usize,
30+
}
31+
32+
pub fn run_purge(
33+
ctx: &Context,
34+
out: Option<String>,
35+
force: bool,
36+
format: Format,
37+
) -> anyhow::Result<()> {
38+
let out_dir = out.unwrap_or_else(|| DEFAULT_OUT_DIR.to_string());
39+
let out_path = Path::new(&out_dir);
40+
if !force {
41+
eprintln!(
42+
"This will purge generated CodeWiki output under {} for project {}. Re-run with --force to confirm.",
43+
out_path.display(),
44+
ctx.project_id
45+
);
46+
return Ok(());
47+
}
48+
49+
let counts = purge_generated_output(out_path)?;
50+
let summary = CodewikiPurgeSummary {
51+
command: "codewiki purge",
52+
project_id: ctx.project_id.clone(),
53+
project_root: ctx.project_root.display().to_string(),
54+
out_dir,
55+
markdown_removed: counts.markdown_removed,
56+
metadata_removed: counts.metadata_removed,
57+
};
58+
match format {
59+
Format::Json => output::print_json(&summary),
60+
Format::Text => output::print_text(&format!(
61+
"purged {} generated Markdown pages and {} metadata files from {}",
62+
summary.markdown_removed, summary.metadata_removed, summary.out_dir
63+
)),
64+
}?;
65+
Ok(())
66+
}
67+
68+
pub(crate) fn purge_generated_output(out_dir: &Path) -> anyhow::Result<CodewikiPurgeCounts> {
69+
let meta = read_codewiki_meta(out_dir)?;
70+
let mut generated_docs = BTreeSet::new();
71+
generated_docs.extend(meta.docs.keys().cloned());
72+
generated_docs.extend(meta.generated_docs);
73+
generated_docs.extend(collect_generated_doc_pages(out_dir)?);
74+
75+
let mut counts = CodewikiPurgeCounts::default();
76+
for doc_path in generated_docs {
77+
if !doc_path.ends_with(".md") {
78+
continue;
79+
}
80+
if remove_generated_path(out_dir, &doc_path)? {
81+
counts.markdown_removed += 1;
82+
}
83+
}
84+
85+
for metadata_path in [
86+
CODEWIKI_META_PATH,
87+
OWNERSHIP_META_PATH,
88+
TRUTH_DIGEST_META_PATH,
89+
] {
90+
if remove_generated_path(out_dir, metadata_path)? {
91+
counts.metadata_removed += 1;
92+
}
93+
}
94+
95+
Ok(counts)
96+
}
97+
98+
fn remove_generated_path(out_dir: &Path, relative_path: &str) -> anyhow::Result<bool> {
99+
let target = safe_doc_path(out_dir, relative_path)?;
100+
reject_symlinked_doc_path(out_dir, &target)?;
101+
match std::fs::remove_file(&target) {
102+
Ok(()) => {
103+
prune_empty_doc_dirs(out_dir, &target)?;
104+
Ok(true)
105+
}
106+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
107+
Err(error) => Err(error.into()),
108+
}
109+
}

crates/gcode/src/commands/codewiki/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ mod modules;
2323
mod onboarding;
2424
mod progress;
2525
mod provenance;
26+
mod purge;
2627
mod repair;
2728
mod reuse;
2829
mod truth_digest;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
use super::*;
2+
3+
#[test]
4+
fn purge_removes_generated_docs_and_metadata_only() -> anyhow::Result<()> {
5+
let temp = tempfile::tempdir()?;
6+
let out_dir = temp.path();
7+
write_doc(out_dir, "code/files/src/lib.rs.md", "# Lib\n")?;
8+
write_doc(out_dir, "code/modules/src.md", "# Src\n")?;
9+
write_doc(out_dir, "notes/manual.md", "# Manual\n")?;
10+
write_doc(
11+
out_dir,
12+
CODEWIKI_META_PATH,
13+
r#"{
14+
"docs": {
15+
"code/files/src/lib.rs.md": { "source_hashes": {} }
16+
},
17+
"generated_docs": ["code/modules/src.md"],
18+
"ai_mode": "off"
19+
}"#,
20+
)?;
21+
write_doc(out_dir, OWNERSHIP_META_PATH, "{}")?;
22+
write_doc(out_dir, TRUTH_DIGEST_META_PATH, "{}")?;
23+
24+
let counts = purge_generated_output(out_dir)?;
25+
26+
assert_eq!(counts.markdown_removed, 2);
27+
assert_eq!(counts.metadata_removed, 3);
28+
assert!(!out_dir.join("code/files/src/lib.rs.md").exists());
29+
assert!(!out_dir.join("code/modules/src.md").exists());
30+
assert!(!out_dir.join(CODEWIKI_META_PATH).exists());
31+
assert!(!out_dir.join(OWNERSHIP_META_PATH).exists());
32+
assert!(!out_dir.join(TRUTH_DIGEST_META_PATH).exists());
33+
assert!(out_dir.join("notes/manual.md").exists());
34+
Ok(())
35+
}
36+
37+
#[test]
38+
fn purge_refuses_unsafe_generated_path_from_metadata() -> anyhow::Result<()> {
39+
let temp = tempfile::tempdir()?;
40+
let out_dir = temp.path();
41+
write_doc(
42+
out_dir,
43+
CODEWIKI_META_PATH,
44+
r#"{
45+
"docs": {
46+
"../outside.md": { "source_hashes": {} },
47+
"code/files/src/lib.rs.md": { "source_hashes": {} }
48+
},
49+
"generated_docs": [],
50+
"ai_mode": "off"
51+
}"#,
52+
)?;
53+
write_doc(out_dir, "code/files/src/lib.rs.md", "# Lib\n")?;
54+
55+
let error = purge_generated_output(out_dir).expect_err("unsafe path must be rejected");
56+
57+
assert!(error.to_string().contains("unsafe codewiki path"));
58+
assert!(out_dir.join("code/files/src/lib.rs.md").exists());
59+
Ok(())
60+
}

crates/gcode/src/dispatch.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ fn service_config_selection(command: &Command) -> config::ServiceConfigSelection
8383
match command {
8484
Command::Index { .. } => ServiceConfigSelection::all(),
8585
Command::Status => ServiceConfigSelection::projection_cleanup(),
86+
Command::Codewiki { purge: true, .. } => ServiceConfigSelection::projection_cleanup(),
8687
Command::Graph { .. }
8788
| Command::Codewiki { .. }
8889
| Command::Callers { .. }
@@ -544,6 +545,8 @@ fn run() -> anyhow::Result<()> {
544545
}
545546
Command::Codewiki {
546547
out,
548+
purge,
549+
force,
547550
scope,
548551
ai,
549552
ai_depth,
@@ -557,10 +560,13 @@ fn run() -> anyhow::Result<()> {
557560
since,
558561
repair_citations,
559562
} => {
560-
ensure_project_fresh(&ctx, cli.no_freshness)?;
563+
if purge {
564+
return commands::codewiki::run_purge(&ctx, out, force, format);
565+
}
561566
if repair_citations {
562567
return commands::codewiki::run_repair(&ctx, out, format);
563568
}
569+
ensure_project_fresh(&ctx, cli.no_freshness)?;
564570
commands::codewiki::run(
565571
&ctx,
566572
out,

crates/gcode/src/dispatch/tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ fn graph_and_ai_commands_request_only_needed_services() {
100100
services_for(&["callers", "needle"]),
101101
config::ServiceConfigSelection::falkordb_only()
102102
);
103+
assert_eq!(
104+
services_for(&["codewiki"]),
105+
config::ServiceConfigSelection::falkordb_only()
106+
);
107+
assert_eq!(
108+
services_for(&["codewiki", "--purge"]),
109+
config::ServiceConfigSelection::projection_cleanup()
110+
);
103111
assert_eq!(
104112
services_for(&["vector", "cleanup-orphans"]),
105113
config::ServiceConfigSelection::qdrant_only()

crates/gcore/src/qdrant.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,26 @@ pub fn delete_points_by_filter(
305305
Ok(())
306306
}
307307

308+
/// Delete a whole Qdrant collection. Missing collections are already purged.
309+
pub fn delete_collection(config: &QdrantConfig, collection: &str) -> anyhow::Result<()> {
310+
let request_path = collection_request_path(collection);
311+
let resp = qdrant_request(config, reqwest::Method::DELETE, &request_path)?.send()?;
312+
let status = resp.status();
313+
if status == StatusCode::NOT_FOUND {
314+
return Ok(());
315+
}
316+
if !status.is_success() {
317+
return Err(qdrant_http_error(
318+
"delete collection",
319+
status,
320+
resp,
321+
collection,
322+
request_path,
323+
));
324+
}
325+
Ok(())
326+
}
327+
308328
fn create_collection(
309329
config: &QdrantConfig,
310330
collection: &str,
@@ -575,6 +595,7 @@ fn operation_method(operation: &str) -> &'static str {
575595
match operation {
576596
"get collection" => "GET",
577597
"create collection" => "PUT",
598+
"delete collection" => "DELETE",
578599
"delete points" => "POST",
579600
"search" => "POST",
580601
"upsert" => "PUT",

0 commit comments

Comments
 (0)