Skip to main content

okf_core/
scaffold.rs

1//! Scaffolding and initialization utilities for OKF bundles and concept documents.
2//!
3//! - [`init_bundle`] initializes a compliant OKF bundle structure (`index.md`,
4//!   `log.md`, and an optional sample concept).
5//! - [`create_concept`] generates a new concept `.md` document with appropriate
6//!   frontmatter and markdown structure.
7
8use crate::date::{Date, DateTime};
9use crate::document::Document;
10use crate::frontmatter::Frontmatter;
11use crate::trust::Status;
12use crate::yaml::{Mapping, Value};
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17/// Options for initializing a new OKF bundle.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct BundleInitOptions {
20    /// Bundle title for the root index.
21    pub title: String,
22    /// Whether to create a sample concept (e.g. `overview.md`).
23    pub create_sample: bool,
24    /// The name/id of the sample concept (default "overview").
25    pub sample_name: String,
26    /// Author attribution for generated frontmatter (e.g. "human:alice").
27    pub author: Option<String>,
28    /// Whether to overwrite existing files if they already exist.
29    pub force: bool,
30}
31
32impl Default for BundleInitOptions {
33    fn default() -> Self {
34        Self {
35            title: "OKF Bundle".to_string(),
36            create_sample: true,
37            sample_name: "overview".to_string(),
38            author: None,
39            force: false,
40        }
41    }
42}
43
44/// Options for creating a new concept document.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct ConceptOptions {
47    /// Concept type (e.g. "Concept", "Metric", "Attested Computation", "Playbook").
48    pub type_: String,
49    /// Concept title (default derived from file name).
50    pub title: Option<String>,
51    /// Concept description.
52    pub description: Option<String>,
53    /// Lifecycle status.
54    pub status: Status,
55    /// Author attribution (e.g. "human:alice").
56    pub author: Option<String>,
57    /// Whether this concept is an Attested Computation contract.
58    pub attested: bool,
59    /// Optional tags.
60    pub tags: Vec<String>,
61    /// Whether to overwrite existing file if it already exists.
62    pub force: bool,
63}
64
65impl Default for ConceptOptions {
66    fn default() -> Self {
67        Self {
68            type_: "Concept".to_string(),
69            title: None,
70            description: None,
71            status: Status::Draft,
72            author: None,
73            attested: false,
74            tags: Vec::new(),
75            force: false,
76        }
77    }
78}
79
80/// Returns a default author actor string based on system user environment
81/// variables or `"human:author"`.
82#[must_use]
83pub fn default_author() -> String {
84    if let Ok(author) = std::env::var("OKF_AUTHOR")
85        && !author.trim().is_empty()
86    {
87        return author.trim().to_string();
88    }
89    for env_key in &["USER", "USERNAME", "LOGNAME"] {
90        if let Ok(user) = std::env::var(env_key) {
91            let clean: String = user
92                .chars()
93                .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
94                .collect();
95            if !clean.is_empty() {
96                return format!("human:{clean}");
97            }
98        }
99    }
100    "human:author".to_string()
101}
102
103/// Capitalizes and formats a file stem into a display title.
104#[must_use]
105pub fn title_from_name(name: &str) -> String {
106    let name = name.trim_end_matches(".md");
107    let mut words = Vec::new();
108    for part in name.split(['_', '-']) {
109        if part.is_empty() {
110            continue;
111        }
112        let mut chars = part.chars();
113        let first = chars.next().unwrap_or_default().to_uppercase().to_string();
114        words.push(format!("{first}{}", chars.as_str()));
115    }
116    if words.is_empty() {
117        "Untitled".to_string()
118    } else {
119        words.join(" ")
120    }
121}
122
123/// Formats the current UTC instant as an ISO-8601 string.
124#[must_use]
125pub fn current_iso_timestamp() -> String {
126    DateTime::now_utc().map_or_else(
127        || "2026-01-01T00:00:00Z".to_string(),
128        |dt| {
129            let clean = DateTime {
130                date: dt.date,
131                hour: dt.hour,
132                minute: dt.minute,
133                second: dt.second,
134                nanosecond: 0,
135                offset_minutes: Some(0),
136                has_time: true,
137            };
138            clean.to_string()
139        },
140    )
141}
142
143/// Builds a scaffolded [`Document`] matching the provided options.
144#[must_use]
145pub fn build_concept_document(options: &ConceptOptions, title: &str) -> Document {
146    let mut fm = Frontmatter::new();
147
148    let is_attested =
149        options.attested || options.type_ == crate::computation::ATTESTED_COMPUTATION_TYPE;
150    let actual_type = if is_attested {
151        crate::computation::ATTESTED_COMPUTATION_TYPE
152    } else {
153        options.type_.as_str()
154    };
155    fm.set("type", Value::String(actual_type.to_string()));
156    fm.set("title", Value::String(title.to_string()));
157
158    let desc = options
159        .description
160        .as_deref()
161        .filter(|d| !d.trim().is_empty())
162        .map_or_else(
163            || format!("Overview and details for {title}."),
164            ToString::to_string,
165        );
166    fm.set("description", Value::String(desc));
167    fm.set("status", Value::String(options.status.to_string()));
168
169    let author = options.author.clone().unwrap_or_else(default_author);
170    let mut gen_map = Mapping::new();
171    gen_map.insert("by", Value::String(author));
172    gen_map.insert("at", Value::String(current_iso_timestamp()));
173    fm.set("generated", Value::Mapping(gen_map));
174
175    if !options.tags.is_empty() {
176        let tag_values: Vec<Value> = options
177            .tags
178            .iter()
179            .map(|t| Value::String(t.clone()))
180            .collect();
181        fm.set("tags", Value::Sequence(tag_values));
182    }
183
184    if is_attested {
185        fm.set("runtime", Value::String("python".to_string()));
186
187        let mut param_map = Mapping::new();
188        param_map.insert("name", Value::String("input_data".to_string()));
189        param_map.insert("type", Value::String("string".to_string()));
190        param_map.insert("required", Value::Bool(true));
191        fm.set(
192            "parameters",
193            Value::Sequence(vec![Value::Mapping(param_map)]),
194        );
195
196        let mut exec_map = Mapping::new();
197        exec_map.insert(
198            "resource",
199            Value::String("references/skills/run.md".to_string()),
200        );
201        exec_map.insert(
202            "receipt",
203            Value::Sequence(vec![Value::String("result".to_string())]),
204        );
205        fm.set("executor", Value::Mapping(exec_map));
206
207        let mut att_map = Mapping::new();
208        att_map.insert(
209            "resource",
210            Value::String("references/attesters/verify.py".to_string()),
211        );
212        fm.set("attester", Value::Mapping(att_map));
213
214        let body = format!(
215            "# {title}\n\n# Computation\n\n```python\n# Sanctioned computation logic\n```\n"
216        );
217        Document::new(fm, body)
218    } else {
219        let body = format!("# {title}\n\nDescribe {title} here.\n");
220        Document::new(fm, body)
221    }
222}
223
224/// Creates a new concept markdown file with proper frontmatter and heading.
225///
226/// # Errors
227///
228/// Returns an [`io::Error`] if the file already exists (and `force` is false),
229/// or if writing to disk fails.
230pub fn create_concept(path: impl AsRef<Path>, options: &ConceptOptions) -> io::Result<PathBuf> {
231    let mut path = path.as_ref().to_path_buf();
232    if path.extension().is_none() {
233        path.set_extension("md");
234    }
235
236    for comp in path.components() {
237        match comp {
238            std::path::Component::Normal(seg) => {
239                if let Some(s) = seg.to_str() {
240                    let s_no_ext = s.strip_suffix(".md").unwrap_or(s);
241                    if let Err(e) = crate::concept_id::validate_segment(s_no_ext) {
242                        return Err(io::Error::new(io::ErrorKind::InvalidInput, e.to_string()));
243                    }
244                }
245            }
246            std::path::Component::ParentDir => {
247                return Err(io::Error::new(
248                    io::ErrorKind::InvalidInput,
249                    "concept path cannot contain `..`",
250                ));
251            }
252            std::path::Component::CurDir
253            | std::path::Component::Prefix(_)
254            | std::path::Component::RootDir => {}
255        }
256    }
257
258    if path.exists() && !options.force {
259        return Err(io::Error::new(
260            io::ErrorKind::AlreadyExists,
261            format!("concept file already exists: {}", path.display()),
262        ));
263    }
264
265    if let Some(parent) = path.parent()
266        && !parent.as_os_str().is_empty()
267    {
268        fs::create_dir_all(parent)?;
269    }
270
271    let file_stem = path
272        .file_stem()
273        .and_then(|s| s.to_str())
274        .unwrap_or("concept");
275    let title = options
276        .title
277        .clone()
278        .unwrap_or_else(|| title_from_name(file_stem));
279
280    let doc = build_concept_document(options, &title);
281    fs::write(&path, doc.serialize())?;
282    Ok(path)
283}
284
285/// Initializes a new OKF bundle at `root` with `index.md`, `log.md`, and
286/// optionally an initial concept.
287///
288/// Returns the list of created file paths on success.
289///
290/// # Errors
291///
292/// Returns an [`io::Error`] if `index.md` or `log.md` already exists (and
293/// `force` is false), or if writing to disk fails.
294pub fn init_bundle(
295    root: impl AsRef<Path>,
296    options: &BundleInitOptions,
297) -> io::Result<Vec<PathBuf>> {
298    let root = root.as_ref();
299    fs::create_dir_all(root)?;
300
301    let index_path = root.join("index.md");
302    let log_path = root.join("log.md");
303
304    if !options.force {
305        if index_path.exists() {
306            return Err(io::Error::new(
307                io::ErrorKind::AlreadyExists,
308                format!("index.md already exists: {}", index_path.display()),
309            ));
310        }
311        if log_path.exists() {
312            return Err(io::Error::new(
313                io::ErrorKind::AlreadyExists,
314                format!("log.md already exists: {}", log_path.display()),
315            ));
316        }
317    }
318
319    let mut created = Vec::new();
320
321    let sample_rel = if options.create_sample {
322        let sample_stem = if options.sample_name.trim().is_empty() {
323            "overview"
324        } else {
325            options.sample_name.trim().trim_end_matches(".md")
326        };
327        let sample_path = root.join(format!("{sample_stem}.md"));
328        let title = title_from_name(sample_stem);
329        let desc = format!("Initial {} concept for this bundle.", title.to_lowercase());
330        let concept_opts = ConceptOptions {
331            type_: "Concept".to_string(),
332            title: Some(title.clone()),
333            description: Some(desc.clone()),
334            status: Status::Draft,
335            author: options.author.clone(),
336            attested: false,
337            tags: Vec::new(),
338            force: options.force,
339        };
340        create_concept(&sample_path, &concept_opts)?;
341        created.push(sample_path);
342        Some((title, format!("{sample_stem}.md"), desc))
343    } else {
344        None
345    };
346
347    // Build index.md
348    let index_text = if let Some((title, link, desc)) = sample_rel {
349        format!(
350            "---\nokf_version: \"{}\"\n---\n\n# Concept\n\n* [{title}]({link}) - {desc}\n",
351            crate::OKF_VERSION
352        )
353    } else {
354        format!(
355            "---\nokf_version: \"{}\"\n---\n\n# {}\n",
356            crate::OKF_VERSION,
357            options.title
358        )
359    };
360    fs::write(&index_path, index_text)?;
361    created.push(index_path);
362
363    // Build log.md
364    let today = Date::today_utc().unwrap_or(Date {
365        year: 2026,
366        month: 1,
367        day: 1,
368    });
369    let log_text = format!("# Update Log\n\n## {today}\n* **Creation**: Initialized OKF bundle.\n");
370    fs::write(&log_path, log_text)?;
371    created.push(log_path);
372
373    Ok(created)
374}