init.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. use std::fs;
  2. use std::path::{Path, PathBuf};
  3. const STARTER_CLAUDE_JSON: &str = concat!(
  4. "{\n",
  5. " \"permissions\": {\n",
  6. " \"defaultMode\": \"dontAsk\"\n",
  7. " }\n",
  8. "}\n",
  9. );
  10. const GITIGNORE_COMMENT: &str = "# Claw Code local artifacts";
  11. const GITIGNORE_ENTRIES: [&str; 2] = [".claude/settings.local.json", ".claude/sessions/"];
  12. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  13. pub(crate) enum InitStatus {
  14. Created,
  15. Updated,
  16. Skipped,
  17. }
  18. impl InitStatus {
  19. #[must_use]
  20. pub(crate) fn label(self) -> &'static str {
  21. match self {
  22. Self::Created => "created",
  23. Self::Updated => "updated",
  24. Self::Skipped => "skipped (already exists)",
  25. }
  26. }
  27. }
  28. #[derive(Debug, Clone, PartialEq, Eq)]
  29. pub(crate) struct InitArtifact {
  30. pub(crate) name: &'static str,
  31. pub(crate) status: InitStatus,
  32. }
  33. #[derive(Debug, Clone, PartialEq, Eq)]
  34. pub(crate) struct InitReport {
  35. pub(crate) project_root: PathBuf,
  36. pub(crate) artifacts: Vec<InitArtifact>,
  37. }
  38. impl InitReport {
  39. #[must_use]
  40. pub(crate) fn render(&self) -> String {
  41. let mut lines = vec![
  42. "Init".to_string(),
  43. format!(" Project {}", self.project_root.display()),
  44. ];
  45. for artifact in &self.artifacts {
  46. lines.push(format!(
  47. " {:<16} {}",
  48. artifact.name,
  49. artifact.status.label()
  50. ));
  51. }
  52. lines.push(" Next step Review and tailor the generated guidance".to_string());
  53. lines.join("\n")
  54. }
  55. }
  56. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  57. #[allow(clippy::struct_excessive_bools)]
  58. struct RepoDetection {
  59. rust_workspace: bool,
  60. rust_root: bool,
  61. python: bool,
  62. package_json: bool,
  63. typescript: bool,
  64. nextjs: bool,
  65. react: bool,
  66. vite: bool,
  67. nest: bool,
  68. src_dir: bool,
  69. tests_dir: bool,
  70. rust_dir: bool,
  71. }
  72. pub(crate) fn initialize_repo(cwd: &Path) -> Result<InitReport, Box<dyn std::error::Error>> {
  73. let mut artifacts = Vec::new();
  74. let claude_dir = cwd.join(".claude");
  75. artifacts.push(InitArtifact {
  76. name: ".claude/",
  77. status: ensure_dir(&claude_dir)?,
  78. });
  79. let claude_json = cwd.join(".claude.json");
  80. artifacts.push(InitArtifact {
  81. name: ".claude.json",
  82. status: write_file_if_missing(&claude_json, STARTER_CLAUDE_JSON)?,
  83. });
  84. let gitignore = cwd.join(".gitignore");
  85. artifacts.push(InitArtifact {
  86. name: ".gitignore",
  87. status: ensure_gitignore_entries(&gitignore)?,
  88. });
  89. let claude_md = cwd.join("CLAUDE.md");
  90. let content = render_init_claude_md(cwd);
  91. artifacts.push(InitArtifact {
  92. name: "CLAUDE.md",
  93. status: write_file_if_missing(&claude_md, &content)?,
  94. });
  95. Ok(InitReport {
  96. project_root: cwd.to_path_buf(),
  97. artifacts,
  98. })
  99. }
  100. fn ensure_dir(path: &Path) -> Result<InitStatus, std::io::Error> {
  101. if path.is_dir() {
  102. return Ok(InitStatus::Skipped);
  103. }
  104. fs::create_dir_all(path)?;
  105. Ok(InitStatus::Created)
  106. }
  107. fn write_file_if_missing(path: &Path, content: &str) -> Result<InitStatus, std::io::Error> {
  108. if path.exists() {
  109. return Ok(InitStatus::Skipped);
  110. }
  111. fs::write(path, content)?;
  112. Ok(InitStatus::Created)
  113. }
  114. fn ensure_gitignore_entries(path: &Path) -> Result<InitStatus, std::io::Error> {
  115. if !path.exists() {
  116. let mut lines = vec![GITIGNORE_COMMENT.to_string()];
  117. lines.extend(GITIGNORE_ENTRIES.iter().map(|entry| (*entry).to_string()));
  118. fs::write(path, format!("{}\n", lines.join("\n")))?;
  119. return Ok(InitStatus::Created);
  120. }
  121. let existing = fs::read_to_string(path)?;
  122. let mut lines = existing.lines().map(ToOwned::to_owned).collect::<Vec<_>>();
  123. let mut changed = false;
  124. if !lines.iter().any(|line| line == GITIGNORE_COMMENT) {
  125. lines.push(GITIGNORE_COMMENT.to_string());
  126. changed = true;
  127. }
  128. for entry in GITIGNORE_ENTRIES {
  129. if !lines.iter().any(|line| line == entry) {
  130. lines.push(entry.to_string());
  131. changed = true;
  132. }
  133. }
  134. if !changed {
  135. return Ok(InitStatus::Skipped);
  136. }
  137. fs::write(path, format!("{}\n", lines.join("\n")))?;
  138. Ok(InitStatus::Updated)
  139. }
  140. pub(crate) fn render_init_claude_md(cwd: &Path) -> String {
  141. let detection = detect_repo(cwd);
  142. let mut lines = vec![
  143. "# CLAUDE.md".to_string(),
  144. String::new(),
  145. "This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.".to_string(),
  146. String::new(),
  147. ];
  148. let detected_languages = detected_languages(&detection);
  149. let detected_frameworks = detected_frameworks(&detection);
  150. lines.push("## Detected stack".to_string());
  151. if detected_languages.is_empty() {
  152. lines.push("- No specific language markers were detected yet; document the primary language and verification commands once the project structure settles.".to_string());
  153. } else {
  154. lines.push(format!("- Languages: {}.", detected_languages.join(", ")));
  155. }
  156. if detected_frameworks.is_empty() {
  157. lines.push("- Frameworks: none detected from the supported starter markers.".to_string());
  158. } else {
  159. lines.push(format!(
  160. "- Frameworks/tooling markers: {}.",
  161. detected_frameworks.join(", ")
  162. ));
  163. }
  164. lines.push(String::new());
  165. let verification_lines = verification_lines(cwd, &detection);
  166. if !verification_lines.is_empty() {
  167. lines.push("## Verification".to_string());
  168. lines.extend(verification_lines);
  169. lines.push(String::new());
  170. }
  171. let structure_lines = repository_shape_lines(&detection);
  172. if !structure_lines.is_empty() {
  173. lines.push("## Repository shape".to_string());
  174. lines.extend(structure_lines);
  175. lines.push(String::new());
  176. }
  177. let framework_lines = framework_notes(&detection);
  178. if !framework_lines.is_empty() {
  179. lines.push("## Framework notes".to_string());
  180. lines.extend(framework_lines);
  181. lines.push(String::new());
  182. }
  183. lines.push("## Working agreement".to_string());
  184. lines.push("- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.".to_string());
  185. lines.push("- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.".to_string());
  186. lines.push("- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.".to_string());
  187. lines.push(String::new());
  188. lines.join("\n")
  189. }
  190. fn detect_repo(cwd: &Path) -> RepoDetection {
  191. let package_json_contents = fs::read_to_string(cwd.join("package.json"))
  192. .unwrap_or_default()
  193. .to_ascii_lowercase();
  194. RepoDetection {
  195. rust_workspace: cwd.join("rust").join("Cargo.toml").is_file(),
  196. rust_root: cwd.join("Cargo.toml").is_file(),
  197. python: cwd.join("pyproject.toml").is_file()
  198. || cwd.join("requirements.txt").is_file()
  199. || cwd.join("setup.py").is_file(),
  200. package_json: cwd.join("package.json").is_file(),
  201. typescript: cwd.join("tsconfig.json").is_file()
  202. || package_json_contents.contains("typescript"),
  203. nextjs: package_json_contents.contains("\"next\""),
  204. react: package_json_contents.contains("\"react\""),
  205. vite: package_json_contents.contains("\"vite\""),
  206. nest: package_json_contents.contains("@nestjs"),
  207. src_dir: cwd.join("src").is_dir(),
  208. tests_dir: cwd.join("tests").is_dir(),
  209. rust_dir: cwd.join("rust").is_dir(),
  210. }
  211. }
  212. fn detected_languages(detection: &RepoDetection) -> Vec<&'static str> {
  213. let mut languages = Vec::new();
  214. if detection.rust_workspace || detection.rust_root {
  215. languages.push("Rust");
  216. }
  217. if detection.python {
  218. languages.push("Python");
  219. }
  220. if detection.typescript {
  221. languages.push("TypeScript");
  222. } else if detection.package_json {
  223. languages.push("JavaScript/Node.js");
  224. }
  225. languages
  226. }
  227. fn detected_frameworks(detection: &RepoDetection) -> Vec<&'static str> {
  228. let mut frameworks = Vec::new();
  229. if detection.nextjs {
  230. frameworks.push("Next.js");
  231. }
  232. if detection.react {
  233. frameworks.push("React");
  234. }
  235. if detection.vite {
  236. frameworks.push("Vite");
  237. }
  238. if detection.nest {
  239. frameworks.push("NestJS");
  240. }
  241. frameworks
  242. }
  243. fn verification_lines(cwd: &Path, detection: &RepoDetection) -> Vec<String> {
  244. let mut lines = Vec::new();
  245. if detection.rust_workspace {
  246. lines.push("- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  247. } else if detection.rust_root {
  248. lines.push("- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`".to_string());
  249. }
  250. if detection.python {
  251. if cwd.join("pyproject.toml").is_file() {
  252. lines.push("- Run the Python project checks declared in `pyproject.toml` (for example: `pytest`, `ruff check`, and `mypy` when configured).".to_string());
  253. } else {
  254. lines.push(
  255. "- Run the repo's Python test/lint commands before shipping changes.".to_string(),
  256. );
  257. }
  258. }
  259. if detection.package_json {
  260. lines.push("- Run the JavaScript/TypeScript checks from `package.json` before shipping changes (`npm test`, `npm run lint`, `npm run build`, or the repo equivalent).".to_string());
  261. }
  262. if detection.tests_dir && detection.src_dir {
  263. lines.push("- `src/` and `tests/` are both present; update both surfaces together when behavior changes.".to_string());
  264. }
  265. lines
  266. }
  267. fn repository_shape_lines(detection: &RepoDetection) -> Vec<String> {
  268. let mut lines = Vec::new();
  269. if detection.rust_dir {
  270. lines.push(
  271. "- `rust/` contains the Rust workspace and active CLI/runtime implementation."
  272. .to_string(),
  273. );
  274. }
  275. if detection.src_dir {
  276. lines.push("- `src/` contains source files that should stay consistent with generated guidance and tests.".to_string());
  277. }
  278. if detection.tests_dir {
  279. lines.push("- `tests/` contains validation surfaces that should be reviewed alongside code changes.".to_string());
  280. }
  281. lines
  282. }
  283. fn framework_notes(detection: &RepoDetection) -> Vec<String> {
  284. let mut lines = Vec::new();
  285. if detection.nextjs {
  286. lines.push("- Next.js detected: preserve routing/data-fetching conventions and verify production builds after changing app structure.".to_string());
  287. }
  288. if detection.react && !detection.nextjs {
  289. lines.push("- React detected: keep component behavior covered with focused tests and avoid unnecessary prop/API churn.".to_string());
  290. }
  291. if detection.vite {
  292. lines.push("- Vite detected: validate the production bundle after changing build-sensitive configuration or imports.".to_string());
  293. }
  294. if detection.nest {
  295. lines.push("- NestJS detected: keep module/provider boundaries explicit and verify controller/service wiring after refactors.".to_string());
  296. }
  297. lines
  298. }
  299. #[cfg(test)]
  300. mod tests {
  301. use super::{initialize_repo, render_init_claude_md};
  302. use std::fs;
  303. use std::path::Path;
  304. use std::time::{SystemTime, UNIX_EPOCH};
  305. fn temp_dir() -> std::path::PathBuf {
  306. let nanos = SystemTime::now()
  307. .duration_since(UNIX_EPOCH)
  308. .expect("time should be after epoch")
  309. .as_nanos();
  310. std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
  311. }
  312. #[test]
  313. fn initialize_repo_creates_expected_files_and_gitignore_entries() {
  314. let root = temp_dir();
  315. fs::create_dir_all(root.join("rust")).expect("create rust dir");
  316. fs::write(root.join("rust").join("Cargo.toml"), "[workspace]\n").expect("write cargo");
  317. let report = initialize_repo(&root).expect("init should succeed");
  318. let rendered = report.render();
  319. assert!(rendered.contains(".claude/ created"));
  320. assert!(rendered.contains(".claude.json created"));
  321. assert!(rendered.contains(".gitignore created"));
  322. assert!(rendered.contains("CLAUDE.md created"));
  323. assert!(root.join(".claude").is_dir());
  324. assert!(root.join(".claude.json").is_file());
  325. assert!(root.join("CLAUDE.md").is_file());
  326. assert_eq!(
  327. fs::read_to_string(root.join(".claude.json")).expect("read claude json"),
  328. concat!(
  329. "{\n",
  330. " \"permissions\": {\n",
  331. " \"defaultMode\": \"dontAsk\"\n",
  332. " }\n",
  333. "}\n",
  334. )
  335. );
  336. let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
  337. assert!(gitignore.contains(".claude/settings.local.json"));
  338. assert!(gitignore.contains(".claude/sessions/"));
  339. let claude_md = fs::read_to_string(root.join("CLAUDE.md")).expect("read claude md");
  340. assert!(claude_md.contains("Languages: Rust."));
  341. assert!(claude_md.contains("cargo clippy --workspace --all-targets -- -D warnings"));
  342. fs::remove_dir_all(root).expect("cleanup temp dir");
  343. }
  344. #[test]
  345. fn initialize_repo_is_idempotent_and_preserves_existing_files() {
  346. let root = temp_dir();
  347. fs::create_dir_all(&root).expect("create root");
  348. fs::write(root.join("CLAUDE.md"), "custom guidance\n").expect("write existing claude md");
  349. fs::write(root.join(".gitignore"), ".claude/settings.local.json\n")
  350. .expect("write gitignore");
  351. let first = initialize_repo(&root).expect("first init should succeed");
  352. assert!(first
  353. .render()
  354. .contains("CLAUDE.md skipped (already exists)"));
  355. let second = initialize_repo(&root).expect("second init should succeed");
  356. let second_rendered = second.render();
  357. assert!(second_rendered.contains(".claude/ skipped (already exists)"));
  358. assert!(second_rendered.contains(".claude.json skipped (already exists)"));
  359. assert!(second_rendered.contains(".gitignore skipped (already exists)"));
  360. assert!(second_rendered.contains("CLAUDE.md skipped (already exists)"));
  361. assert_eq!(
  362. fs::read_to_string(root.join("CLAUDE.md")).expect("read existing claude md"),
  363. "custom guidance\n"
  364. );
  365. let gitignore = fs::read_to_string(root.join(".gitignore")).expect("read gitignore");
  366. assert_eq!(gitignore.matches(".claude/settings.local.json").count(), 1);
  367. assert_eq!(gitignore.matches(".claude/sessions/").count(), 1);
  368. fs::remove_dir_all(root).expect("cleanup temp dir");
  369. }
  370. #[test]
  371. fn render_init_template_mentions_detected_python_and_nextjs_markers() {
  372. let root = temp_dir();
  373. fs::create_dir_all(&root).expect("create root");
  374. fs::write(root.join("pyproject.toml"), "[project]\nname = \"demo\"\n")
  375. .expect("write pyproject");
  376. fs::write(
  377. root.join("package.json"),
  378. r#"{"dependencies":{"next":"14.0.0","react":"18.0.0"},"devDependencies":{"typescript":"5.0.0"}}"#,
  379. )
  380. .expect("write package json");
  381. let rendered = render_init_claude_md(Path::new(&root));
  382. assert!(rendered.contains("Languages: Python, TypeScript."));
  383. assert!(rendered.contains("Frameworks/tooling markers: Next.js, React."));
  384. assert!(rendered.contains("pyproject.toml"));
  385. assert!(rendered.contains("Next.js detected"));
  386. fs::remove_dir_all(root).expect("cleanup temp dir");
  387. }
  388. }