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