Skip to main content

ito_core/
planning_init.rs

1//! Planning directory initialization.
2//!
3//! This module owns the filesystem I/O for bootstrapping the planning area.
4//! Pure path helpers remain in `ito_domain::planning`.
5
6use crate::errors::{CoreError, CoreResult};
7use ito_domain::planning::{planning_dir, research_dir};
8use std::io::ErrorKind;
9use std::path::{Path, PathBuf};
10
11/// Current state of the flexible planning workspace.
12#[derive(Debug, Eq, PartialEq)]
13pub struct PlanningWorkspaceStatus {
14    /// Path to the `.ito/planning/` workspace.
15    pub planning_dir: PathBuf,
16    /// Whether the planning workspace directory exists.
17    pub planning_exists: bool,
18    /// Whether the planning path exists but is not a directory.
19    pub planning_invalid: bool,
20    /// Markdown planning documents currently present directly under the workspace.
21    pub planning_documents: Vec<PathBuf>,
22    /// Path to the companion `.ito/research/` workspace.
23    pub research_dir: PathBuf,
24    /// Whether the companion research workspace exists.
25    pub research_exists: bool,
26    /// Whether the research path exists but is not a directory.
27    pub research_invalid: bool,
28}
29
30/// Adapter-ready summary for `ito plan status` rendering.
31#[derive(Debug, Eq, PartialEq)]
32pub struct PlanningWorkspaceSummary {
33    /// Planning workspace availability label.
34    pub planning_status: &'static str,
35    /// Path to the planning workspace.
36    pub planning_dir: PathBuf,
37    /// Research workspace availability label.
38    pub research_status: &'static str,
39    /// Path to the research workspace.
40    pub research_dir: PathBuf,
41    /// Planning-specific notice to display before the documents section.
42    pub planning_notice: Option<String>,
43    /// Research-specific notice to display before the documents section.
44    pub research_notice: Option<String>,
45    /// Documents-section message when there is nothing to list.
46    pub documents_notice: Option<String>,
47    /// Prepared planning document names for rendering.
48    pub document_names: Vec<String>,
49}
50
51struct WorkspacePathState {
52    exists: bool,
53    invalid: bool,
54}
55
56/// Initialize the planning directory structure under `ito_path`.
57///
58/// This is safe to call multiple times; existing planning documents are left unchanged.
59///
60/// # Errors
61///
62/// Returns an error if:
63/// - The planning workspace cannot be created because the path is not writable or conflicts with a file.
64/// - The research workspace metadata cannot be inspected because of a filesystem access error.
65/// - The research workspace is missing and cannot be created because the parent path is not writable.
66pub fn init_planning_structure(ito_path: &Path) -> CoreResult<()> {
67    let planning = planning_dir(ito_path);
68    std::fs::create_dir_all(&planning)
69        .map_err(|e| workspace_io_error("creating planning workspace", &planning, e))?;
70    let research = research_dir(ito_path);
71    match std::fs::metadata(&research) {
72        Ok(metadata) if metadata.is_dir() => {}
73        Ok(_) => {}
74        Err(err) if err.kind() == ErrorKind::NotFound => std::fs::create_dir_all(&research)
75            .map_err(|e| workspace_io_error("creating research workspace", &research, e))?,
76        Err(err) => {
77            return Err(workspace_io_error(
78                "inspecting research workspace",
79                &research,
80                err,
81            ));
82        }
83    }
84    Ok(())
85}
86
87/// Convert a [`PlanningWorkspaceStatus`] into a [`PlanningWorkspaceSummary`] for CLI rendering.
88///
89/// Use this after [`read_planning_workspace_status`] when a command needs stable
90/// user-facing labels, notices, and document names without re-encoding the
91/// workspace-state branching in the adapter layer.
92pub fn summarize_planning_workspace(status: &PlanningWorkspaceStatus) -> PlanningWorkspaceSummary {
93    let document_names = status
94        .planning_documents
95        .iter()
96        .map(|document| {
97            document
98                .file_name()
99                .unwrap_or_else(|| document.as_os_str())
100                .to_string_lossy()
101                .into_owned()
102        })
103        .collect();
104
105    PlanningWorkspaceSummary {
106        planning_status: workspace_label(status.planning_exists, status.planning_invalid),
107        planning_dir: status.planning_dir.clone(),
108        research_status: workspace_label(status.research_exists, status.research_invalid),
109        research_dir: status.research_dir.clone(),
110        planning_notice: status.planning_invalid.then(|| {
111            "Planning path is not a directory. Rename or remove it, then run `ito plan init`."
112                .to_string()
113        }),
114        research_notice: status.research_invalid.then(|| {
115            "Research path is not a directory. Rename or remove it before storing deep-dive research."
116                .to_string()
117        }),
118        documents_notice: if status.planning_invalid {
119            None
120        } else if !status.planning_exists {
121            Some("No planning workspace found. Run `ito plan init` to create one.".to_string())
122        } else if status.planning_documents.is_empty() {
123            Some("No planning documents yet. Use ito-proposal to create the first plan.".to_string())
124        } else {
125            None
126        },
127        document_names,
128    }
129}
130
131/// Inspect the flexible planning workspace.
132///
133/// # Errors
134///
135/// Returns an error if:
136/// - The planning workspace exists but cannot be read because directory listing or entry metadata access fails.
137/// - The research workspace path cannot be inspected because filesystem metadata access fails.
138pub fn read_planning_workspace_status(ito_path: &Path) -> CoreResult<PlanningWorkspaceStatus> {
139    let planning = planning_dir(ito_path);
140    let planning_state = inspect_workspace_path(&planning, "planning")?;
141    let mut planning_documents = Vec::new();
142
143    if planning_state.exists {
144        let entries = std::fs::read_dir(&planning)
145            .map_err(|e| workspace_io_error("reading planning workspace", &planning, e))?;
146        for entry in entries {
147            let entry = entry.map_err(|e| {
148                workspace_io_error("reading planning workspace entry", &planning, e)
149            })?;
150            let path = entry.path();
151            let file_type = entry.file_type().map_err(|e| {
152                workspace_io_error("reading planning workspace entry metadata", &path, e)
153            })?;
154            let mut is_markdown = false;
155            if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
156                is_markdown =
157                    ext.eq_ignore_ascii_case("md") || ext.eq_ignore_ascii_case("markdown");
158            }
159            if is_markdown && file_type.is_file() {
160                planning_documents.push(path);
161            }
162        }
163        planning_documents.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
164    }
165
166    let research = research_dir(ito_path);
167    let research_state = inspect_workspace_path(&research, "research")?;
168
169    Ok(PlanningWorkspaceStatus {
170        planning_dir: planning,
171        planning_exists: planning_state.exists,
172        planning_invalid: planning_state.invalid,
173        planning_documents,
174        research_dir: research,
175        research_exists: research_state.exists,
176        research_invalid: research_state.invalid,
177    })
178}
179
180fn inspect_workspace_path(path: &Path, label: &str) -> CoreResult<WorkspacePathState> {
181    match std::fs::metadata(path) {
182        Ok(metadata) => {
183            let is_dir = metadata.is_dir();
184            Ok(WorkspacePathState {
185                exists: is_dir,
186                invalid: !is_dir,
187            })
188        }
189        Err(err) if err.kind() == ErrorKind::NotFound => Ok(WorkspacePathState {
190            exists: false,
191            invalid: false,
192        }),
193        Err(err) => Err(workspace_io_error(
194            format!("inspecting {label} workspace"),
195            path,
196            err,
197        )),
198    }
199}
200
201fn workspace_label(exists: bool, invalid: bool) -> &'static str {
202    if exists {
203        "available"
204    } else if invalid {
205        "invalid"
206    } else {
207        "missing"
208    }
209}
210
211fn workspace_io_error(action: impl AsRef<str>, path: &Path, err: std::io::Error) -> CoreError {
212    CoreError::io(
213        format!(
214            "{} at {} (check permissions and parent directories)",
215            action.as_ref(),
216            path.display()
217        ),
218        err,
219    )
220}