1use 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#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct BundleInitOptions {
20 pub title: String,
22 pub create_sample: bool,
24 pub sample_name: String,
26 pub author: Option<String>,
28 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#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct ConceptOptions {
47 pub type_: String,
49 pub title: Option<String>,
51 pub description: Option<String>,
53 pub status: Status,
55 pub author: Option<String>,
57 pub attested: bool,
59 pub tags: Vec<String>,
61 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#[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#[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#[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#[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
224pub 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
285pub 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 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 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}