Skip to main content

algocline_app/service/
init.rs

1//! `alc_init` — initialize `alc.toml` in a project root.
2
3use std::path::Path;
4
5use super::alc_toml::{alc_toml_path, save_alc_toml};
6use super::AppService;
7
8/// Entries to ensure are present in `.gitignore` after `alc_init`.
9///
10/// - `alc.local.toml` — variant-scoped package overrides (decisions.md Q1).
11///   The filename follows the dotenv `.env.local` convention so "gitignored"
12///   reads at a glance; the logical scope name (`variant`) and the physical
13///   filename are intentionally asymmetric.
14/// - `.alc-install.lock` — advisory flock companion created by
15///   `pkg_install::project_files_lock_path` in the project root when
16///   `alc_pkg_install` serializes concurrent writes to `alc.toml` / `alc.lock`.
17///   Adding it up-front avoids surprising a user who runs `pkg_install` inside
18///   a checkout and then sees the lock file in `git status`.
19const GITIGNORE_ENTRIES: &[&str] = &["alc.local.toml", ".alc-install.lock"];
20
21impl AppService {
22    pub async fn init(&self, project_root: Option<String>) -> Result<String, String> {
23        // resolve: explicit → session pin → ALC_PROJECT_ROOT → walk_up (None if alc.toml absent) → cwd
24        let root = match self.resolve_root(project_root.as_deref()) {
25            Some(r) => r,
26            None => std::env::current_dir().map_err(|e| format!("Cannot determine cwd: {e}"))?,
27        };
28
29        let path = alc_toml_path(&root);
30        if path.exists() {
31            return Err(format!("alc.toml already exists at {}", path.display()));
32        }
33
34        let doc: toml_edit::DocumentMut = "[packages]\n"
35            .parse()
36            .map_err(|e: toml_edit::TomlError| format!("Internal error: {e}"))?;
37        save_alc_toml(&root, &doc)?;
38
39        // Best-effort .gitignore append. Failures are surfaced to the caller
40        // rather than swallowed — the whole point of `alc_init` is to set up
41        // a reproducible project shape, and a silent gitignore failure
42        // would leak algocline-internal files into VCS later.
43        //
44        // `gitignore_updated` is the OR across all managed entries: `true` when
45        // any entry was newly written, `false` only when every entry was
46        // already present.
47        let gitignore_path = root.join(".gitignore");
48        let mut gitignore_updated = false;
49        for entry in GITIGNORE_ENTRIES {
50            if update_gitignore(&root, entry)? {
51                gitignore_updated = true;
52            }
53        }
54
55        let result = serde_json::json!({
56            "created": path.display().to_string(),
57            "gitignore_path": gitignore_path.display().to_string(),
58            "gitignore_updated": gitignore_updated,
59        });
60        Ok(result.to_string())
61    }
62}
63
64/// Ensure `entry` appears as a line in `{root}/.gitignore`.
65///
66/// - Missing file → create with just `entry\n`.
67/// - Present, entry already on its own line (ignoring surrounding whitespace)
68///   → no-op.
69/// - Present but entry absent → append `entry\n`, inserting a leading newline
70///   if the existing file does not end in one.
71///
72/// Returns `Ok(true)` when the file was written, `Ok(false)` when the entry
73/// was already present. Comment-style matches (`# alc.local.toml`) are not
74/// treated as existing entries — they're comments, not patterns.
75pub(crate) fn update_gitignore(root: &Path, entry: &str) -> Result<bool, String> {
76    let path = root.join(".gitignore");
77
78    if !path.exists() {
79        std::fs::write(&path, format!("{entry}\n"))
80            .map_err(|e| format!("Failed to create {}: {e}", path.display()))?;
81        return Ok(true);
82    }
83
84    let existing = std::fs::read_to_string(&path)
85        .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
86
87    let already_present = existing.lines().any(|line| {
88        let trimmed = line.trim();
89        !trimmed.starts_with('#') && trimmed == entry
90    });
91
92    if already_present {
93        return Ok(false);
94    }
95
96    let mut new_content = existing;
97    if !new_content.is_empty() && !new_content.ends_with('\n') {
98        new_content.push('\n');
99    }
100    new_content.push_str(entry);
101    new_content.push('\n');
102
103    std::fs::write(&path, new_content)
104        .map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
105    Ok(true)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::update_gitignore;
111    use crate::service::test_support::make_app_service as make_service;
112
113    #[tokio::test]
114    async fn init_creates_alc_toml() {
115        let tmp = tempfile::tempdir().unwrap();
116        let svc = make_service().await;
117        let result = svc
118            .init(Some(tmp.path().to_str().unwrap().to_string()))
119            .await
120            .unwrap();
121        assert!(result.contains("created"));
122        assert!(tmp.path().join("alc.toml").exists());
123
124        let content = std::fs::read_to_string(tmp.path().join("alc.toml")).unwrap();
125        assert!(content.contains("[packages]"));
126    }
127
128    #[tokio::test]
129    async fn init_fails_if_alc_toml_exists() {
130        let tmp = tempfile::tempdir().unwrap();
131        std::fs::write(tmp.path().join("alc.toml"), "[packages]\n").unwrap();
132        let svc = make_service().await;
133        let err = svc
134            .init(Some(tmp.path().to_str().unwrap().to_string()))
135            .await
136            .unwrap_err();
137        assert!(err.contains("already exists"));
138    }
139
140    #[tokio::test]
141    async fn init_creates_gitignore_when_absent() {
142        let tmp = tempfile::tempdir().unwrap();
143        let svc = make_service().await;
144        let raw = svc
145            .init(Some(tmp.path().to_str().unwrap().to_string()))
146            .await
147            .unwrap();
148
149        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
150        assert_eq!(json["gitignore_updated"], true);
151
152        let gi = tmp.path().join(".gitignore");
153        assert!(gi.exists());
154        let content = std::fs::read_to_string(&gi).unwrap();
155        assert_eq!(content, "alc.local.toml\n.alc-install.lock\n");
156    }
157
158    #[tokio::test]
159    async fn init_appends_to_existing_gitignore() {
160        let tmp = tempfile::tempdir().unwrap();
161        let gi = tmp.path().join(".gitignore");
162        std::fs::write(&gi, "target\nworkspace\n").unwrap();
163
164        let svc = make_service().await;
165        let raw = svc
166            .init(Some(tmp.path().to_str().unwrap().to_string()))
167            .await
168            .unwrap();
169        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
170        assert_eq!(json["gitignore_updated"], true);
171
172        let content = std::fs::read_to_string(&gi).unwrap();
173        assert_eq!(
174            content,
175            "target\nworkspace\nalc.local.toml\n.alc-install.lock\n"
176        );
177    }
178
179    #[tokio::test]
180    async fn init_is_idempotent_on_gitignore_entries() {
181        let tmp = tempfile::tempdir().unwrap();
182        let gi = tmp.path().join(".gitignore");
183        std::fs::write(
184            &gi,
185            "target\nalc.local.toml\n.alc-install.lock\nworkspace\n",
186        )
187        .unwrap();
188
189        let svc = make_service().await;
190        let raw = svc
191            .init(Some(tmp.path().to_str().unwrap().to_string()))
192            .await
193            .unwrap();
194        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
195        assert_eq!(json["gitignore_updated"], false);
196
197        // File unchanged.
198        let content = std::fs::read_to_string(&gi).unwrap();
199        assert_eq!(
200            content,
201            "target\nalc.local.toml\n.alc-install.lock\nworkspace\n"
202        );
203    }
204
205    #[tokio::test]
206    async fn init_partial_existing_gitignore_updates_missing_entry_only() {
207        // One of the two managed entries already exists; `gitignore_updated`
208        // must still be `true` because the second is appended.
209        let tmp = tempfile::tempdir().unwrap();
210        let gi = tmp.path().join(".gitignore");
211        std::fs::write(&gi, "target\nalc.local.toml\n").unwrap();
212
213        let svc = make_service().await;
214        let raw = svc
215            .init(Some(tmp.path().to_str().unwrap().to_string()))
216            .await
217            .unwrap();
218        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
219        assert_eq!(json["gitignore_updated"], true);
220
221        let content = std::fs::read_to_string(&gi).unwrap();
222        assert_eq!(content, "target\nalc.local.toml\n.alc-install.lock\n");
223    }
224
225    #[tokio::test]
226    async fn update_gitignore_adds_trailing_newline_if_missing() {
227        let tmp = tempfile::tempdir().unwrap();
228        let gi = tmp.path().join(".gitignore");
229        std::fs::write(&gi, "target").unwrap(); // no trailing \n
230
231        let updated = update_gitignore(tmp.path(), "alc.local.toml").unwrap();
232        assert!(updated);
233
234        let content = std::fs::read_to_string(&gi).unwrap();
235        assert_eq!(content, "target\nalc.local.toml\n");
236    }
237
238    #[tokio::test]
239    async fn update_gitignore_does_not_match_commented_line() {
240        // A commented-out `# alc.local.toml` must not be mistaken for an
241        // existing entry — the entry is still absent.
242        let tmp = tempfile::tempdir().unwrap();
243        let gi = tmp.path().join(".gitignore");
244        std::fs::write(&gi, "# alc.local.toml\ntarget\n").unwrap();
245
246        let updated = update_gitignore(tmp.path(), "alc.local.toml").unwrap();
247        assert!(updated);
248
249        let content = std::fs::read_to_string(&gi).unwrap();
250        assert_eq!(content, "# alc.local.toml\ntarget\nalc.local.toml\n");
251    }
252}