Skip to main content

memnite_cli/
project_resolve.rs

1//! Resolve the current project from the environment when it is not given explicitly, then
2//! canonicalize it. This is the impure shell (config file / git / cwd I/O) around the pure
3//! `normalize_project` in memnite-core. Resolution never emits an event and never fails: it
4//! always falls back to the cwd directory name.
5
6use memnite_core::normalize_project;
7use std::path::{Path, PathBuf};
8
9/// Where a resolved project name came from (precedence order, first match wins).
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum ProjectSource {
12    Explicit,
13    Config,
14    GitRemote,
15    GitRoot,
16    Cwd,
17}
18
19/// A resolved, already-normalized project name plus the source it was derived from.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct ResolvedProject {
22    pub project: String,
23    pub source: ProjectSource,
24}
25
26/// Resolve the project for `cwd`. Precedence: an explicit name → `.memnite/config.json`
27/// (`project_name`, searched from cwd upward) → the git `origin` remote's repo name → the
28/// git root directory name → the cwd directory name. The returned `project` is always
29/// normalized; the function never returns an error.
30pub fn resolve_project(cwd: &Path, explicit: Option<&str>) -> ResolvedProject {
31    // Each link is skipped when its canonical form is empty (separators-only / blank = no
32    // usable project); the cwd basename is the last resort.
33    if let Some(r) = explicit.and_then(|n| resolved_nonempty(n, ProjectSource::Explicit)) {
34        return r;
35    }
36    if let Some(r) = config_project(cwd).and_then(|n| resolved_nonempty(&n, ProjectSource::Config))
37    {
38        return r;
39    }
40    if let Some(git_root) = find_git_root(cwd) {
41        if let Some(r) = git_remote_origin(&git_root)
42            .and_then(|url| resolved_nonempty(&repo_name_from_url(&url), ProjectSource::GitRemote))
43        {
44            return r;
45        }
46        // A git root always has a directory name, so this is the terminal link once inside a repo.
47        return resolved(&basename(&git_root), ProjectSource::GitRoot);
48    }
49    resolved(&basename(cwd), ProjectSource::Cwd)
50}
51
52fn resolved(raw: &str, source: ProjectSource) -> ResolvedProject {
53    ResolvedProject {
54        project: normalize_project(raw),
55        source,
56    }
57}
58
59/// Like `resolved`, but `None` when the canonical form is empty (not a usable project).
60fn resolved_nonempty(raw: &str, source: ProjectSource) -> Option<ResolvedProject> {
61    let r = resolved(raw, source);
62    (!r.project.is_empty()).then_some(r)
63}
64
65/// A notice when an explicitly-given project name is not already canonical, e.g.
66/// `Some("project 'My-Repo' normalized to 'my-repo'")`; `None` when it is unchanged. The
67/// surface (CLI stderr / MCP response) shows it — canonicalization itself never fails.
68pub fn normalization_notice(raw: &str) -> Option<String> {
69    let canon = normalize_project(raw);
70    (canon != raw).then(|| format!("project '{raw}' normalized to '{canon}'"))
71}
72
73/// Nearest `.memnite/config.json` from `cwd` upward with a `project_name` string.
74fn config_project(cwd: &Path) -> Option<String> {
75    for dir in cwd.ancestors() {
76        let cfg = dir.join(".memnite").join("config.json");
77        if cfg.is_file() {
78            let text = std::fs::read_to_string(&cfg).ok()?;
79            let value: serde_json::Value = serde_json::from_str(&text).ok()?;
80            return value
81                .get("project_name")
82                .and_then(|v| v.as_str())
83                .map(str::to_string);
84        }
85    }
86    None
87}
88
89/// Nearest ancestor containing a `.git` entry (directory or file), i.e. the git root.
90fn find_git_root(cwd: &Path) -> Option<PathBuf> {
91    cwd.ancestors()
92        .find(|dir| dir.join(".git").exists())
93        .map(Path::to_path_buf)
94}
95
96/// The `url` of `[remote "origin"]` in `<git_root>/.git/config`, if any.
97fn git_remote_origin(git_root: &Path) -> Option<String> {
98    let text = std::fs::read_to_string(git_root.join(".git").join("config")).ok()?;
99    let mut in_origin = false;
100    for line in text.lines() {
101        let t = line.trim();
102        if t.starts_with('[') {
103            in_origin = t == "[remote \"origin\"]";
104        } else if in_origin {
105            if let Some(rest) = t.strip_prefix("url") {
106                if let Some(url) = rest.trim_start().strip_prefix('=') {
107                    return Some(url.trim().to_string());
108                }
109            }
110        }
111    }
112    None
113}
114
115/// Repo name from a git remote URL: the last `/`- or `:`-separated segment, minus `.git`.
116/// Handles HTTPS (`https://host/Org/Repo.git`) and SSH (`git@host:Org/Repo.git`).
117fn repo_name_from_url(url: &str) -> String {
118    let url = url.trim_end_matches('/');
119    let last = url.rsplit(['/', ':']).next().unwrap_or(url);
120    last.strip_suffix(".git").unwrap_or(last).to_string()
121}
122
123fn basename(path: &Path) -> String {
124    path.file_name()
125        .and_then(|s| s.to_str())
126        .unwrap_or("")
127        .to_string()
128}