/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/config.reef Authors: Chris Tusa License: Description: Registry parse/serialize/migrate (schema 1 -> 2 -> 3) and per-project overrides ******************************************************************************/ module config import core.str import core.result_generic as rg import encoding.toml as toml import io.file as iofile import io.dir as iodir import io.path as iopath import paths export type Defaults type Host type Project type Override type Mount type Registry type EffectiveConfig fn parse_registry(toml_text: string): rg.Result[Registry, string] fn serialize_registry(reg: Registry): string fn parse_override(toml_text: string): rg.Result[Override, string] fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig fn with_projects(reg: Registry, new_projects: [Project]): Registry fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] fn remove_project(reg: Registry, name: string): rg.Result[Registry, string] fn registry_path(home_dir: string): string fn default_registry(home_dir: string): Registry fn load_or_init(home_dir: string): rg.Result[Registry, string] fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] end export type Host = struct lan_ip: string end Host type Defaults = struct repos_root: string backup_root: string logdir: string incus_project: string default_image: string profiles: [string] end Defaults type Project = struct name: string repo: string image: string profiles: [string] created: string last_sync: string backup: bool end Project type Mount = struct source: string path: string end Mount type Override = struct image: string profiles: [string] has_profiles: bool mounts: [Mount] env_keys: [string] env_values: [string] end Override type Registry = struct schema: int host: Host output: string defaults: Defaults projects: [Project] end Registry type EffectiveConfig = struct name: string repo: string repo_path: string image: string profiles: [string] mounts: [Mount] env_keys: [string] env_values: [string] end EffectiveConfig // Extract the host portion of an `http://:` URL string. // Returns "" if the input doesn't match that shape. fn extract_lan_ip_from_url(url: string): string let n: int = str.length(url) if n == 0 return "" end if let prefix: string = "http://" let prefix_len: int = 7 if not str.starts_with(url, prefix) return "" end if // Find ':' after the prefix mut i: int = prefix_len while i < n and url[i] != ':' i = i + 1 end while if i <= prefix_len return "" end if return str.substring(url, prefix_len, i - prefix_len) end extract_lan_ip_from_url // Parse a comma-separated TOML inline array of strings: `["a", "b"]`. // Tolerates whitespace and missing brackets; returns empty if input is empty. fn parse_string_array(raw: string): [string] let n: int = str.length(raw) if n == 0 return new [string](0) end if // Strip leading [ and trailing ] mut start: int = 0 mut end_idx: int = n if n > 0 and raw[0] == '[' start = 1 end if if end_idx > start and raw[end_idx - 1] == ']' end_idx = end_idx - 1 end if let inner: string = str.substring(raw, start, end_idx - start) // Split on commas, then trim quotes and whitespace from each element. let inner_len: int = str.length(inner) if inner_len == 0 return new [string](0) end if // Reef doesn't have a dynamic split-and-collect; use str.split with a max. mut parts: [string] = new [string](64) let count: int = str.split(inner, ',', parts, 64) mut result: [string] = new [string](count) mut i: int = 0 while i < count let p: string = str.trim_ws(parts[i]) // Strip surrounding double quotes if present let pl: int = str.length(p) if pl >= 2 and p[0] == '"' and p[pl - 1] == '"' result[i] = str.substring(p, 1, pl - 2) else result[i] = p end if i = i + 1 end while return result end parse_string_array fn parse_registry(toml_text: string): rg.Result[Registry, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @Result[Registry, string].Err("registry too large (>1024 entries)") end if let schema: int = toml.toml_get_int_doc(doc, "repoman.schema") if schema != 1 and schema != 2 and schema != 3 return @Result[Registry, string].Err("unsupported schema (expected 1, 2, or 3)") end if // [repoman].output: "quiet" (default) | "verbose" mut output: string = toml.toml_get_doc(doc, "repoman.output") if str.length(output) == 0 output = "quiet" end if if output != "quiet" and output != "verbose" return @Result[Registry, string].Err("invalid [repoman].output: " + output + " (expected 'quiet' or 'verbose')") end if // [defaults].logdir: ~/.local/state/repoman by default mut logdir: string = toml.toml_get_doc(doc, "defaults.logdir") if str.length(logdir) == 0 logdir = "~/.local/state/repoman" end if let defaults: Defaults = Defaults { repos_root: toml.toml_get_doc(doc, "defaults.repos_root"), backup_root: toml.toml_get_doc(doc, "defaults.backup_root"), logdir: logdir, incus_project: toml.toml_get_doc(doc, "defaults.incus_project"), default_image: toml.toml_get_doc(doc, "defaults.default_image"), profiles: parse_string_array(toml.toml_get_doc(doc, "defaults.profiles")) } let project_count: int = toml.toml_array_count(doc.keys, doc.count, "project") mut projects: [Project] = new [Project](project_count) mut i: int = 0 while i < project_count let p: Project = Project { name: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "name"), repo: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "repo"), image: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "image"), profiles: parse_string_array(toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "profiles")), created: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "created"), last_sync: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "last_sync"), backup: toml.toml_array_get(doc.keys, doc.values, doc.count, "project", i, "backup") != "false" } projects[i] = p i = i + 1 end while // [host].lan_ip — schema 3 reads it directly; schema 2 migration extracts from // the now-removed [defaults.llm.ollama_url]; schema 1 has no such value (empty). mut lan_ip: string = toml.toml_get_doc(doc, "host.lan_ip") if str.length(lan_ip) == 0 // Fall back: schema 2 migration via [defaults.llm.ollama_url] let old_url: string = toml.toml_get_doc(doc, "defaults.llm.ollama_url") if str.length(old_url) > 0 lan_ip = extract_lan_ip_from_url(old_url) end if end if let host: Host = Host { lan_ip: lan_ip } let reg: Registry = Registry { schema: 3, host: host, output: output, defaults: defaults, projects: projects } return @Result[Registry, string].Ok(reg) end parse_registry fn serialize_registry(reg: Registry): string let b: toml.TomlBuilder = toml.toml_builder() toml.toml_begin_table(b, "repoman") toml.toml_set_int(b, "schema", reg.schema) toml.toml_set_string(b, "output", reg.output) toml.toml_begin_table(b, "host") toml.toml_set_string(b, "lan_ip", reg.host.lan_ip) toml.toml_begin_table(b, "defaults") toml.toml_set_string(b, "repos_root", reg.defaults.repos_root) toml.toml_set_string(b, "backup_root", reg.defaults.backup_root) toml.toml_set_string(b, "logdir", reg.defaults.logdir) toml.toml_set_string(b, "incus_project", reg.defaults.incus_project) toml.toml_set_string(b, "default_image", reg.defaults.default_image) toml.toml_set_string_array(b, "profiles", reg.defaults.profiles) let pn: int = reg.projects.length() mut i: int = 0 while i < pn let p: Project = reg.projects[i] toml.toml_array_append_table(b, "project") toml.toml_set_string(b, "name", p.name) toml.toml_set_string(b, "repo", p.repo) toml.toml_set_string(b, "image", p.image) toml.toml_set_string_array(b, "profiles", p.profiles) toml.toml_set_string(b, "created", p.created) toml.toml_set_string(b, "last_sync", p.last_sync) toml.toml_set_bool(b, "backup", p.backup) i = i + 1 end while return toml.toml_render(b) end serialize_registry fn parse_override(toml_text: string): rg.Result[Override, string] let doc: toml.TomlDoc = toml.toml_parse_doc(toml_text) if doc.truncated return @Result[Override, string].Err("override too large") end if let image: string = toml.toml_get_doc(doc, "container.image") let profiles_raw: string = toml.toml_get_doc(doc, "container.profiles") let has_profiles: bool = toml.toml_has_key_doc(doc, "container.profiles") let profiles: [string] = parse_string_array(profiles_raw) let mount_count: int = toml.toml_array_count(doc.keys, doc.count, "mount") mut mounts: [Mount] = new [Mount](mount_count) mut i: int = 0 while i < mount_count mounts[i] = Mount { source: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "source"), path: toml.toml_array_get(doc.keys, doc.values, doc.count, "mount", i, "path") } i = i + 1 end while // Walk doc.keys for entries starting with "env." mut env_keys_buf: [string] = new [string](128) mut env_vals_buf: [string] = new [string](128) mut env_count: int = 0 mut k: int = 0 while k < doc.count let key: string = doc.keys[k] if str.starts_with(key, "env.") if env_count < 128 env_keys_buf[env_count] = str.substring(key, 4, str.length(key) - 4) env_vals_buf[env_count] = doc.values[k] env_count = env_count + 1 end if end if k = k + 1 end while mut env_keys: [string] = new [string](env_count) mut env_vals: [string] = new [string](env_count) mut j: int = 0 while j < env_count env_keys[j] = env_keys_buf[j] env_vals[j] = env_vals_buf[j] j = j + 1 end while let ov: Override = Override { image: image, profiles: profiles, has_profiles: has_profiles, mounts: mounts, env_keys: env_keys, env_values: env_vals } return @Result[Override, string].Ok(ov) end parse_override fn merge_with_defaults(name: string, repo: string, image_flag: string, ov: Override, d: Defaults): EffectiveConfig let repos_root_expanded: string = paths.expand_home(d.repos_root) let repo_path: string = paths.join(repos_root_expanded, repo) // Image priority: flag → override → defaults mut image: string = d.default_image if str.length(ov.image) > 0 image = ov.image end if if str.length(image_flag) > 0 image = image_flag end if // Profiles: override replaces defaults when has_profiles, else defaults mut profiles: [string] = d.profiles if ov.has_profiles profiles = ov.profiles end if // Mounts: [auto repo bind] ++ override.mounts let ov_mount_count: int = ov.mounts.length() mut mounts: [Mount] = new [Mount](1 + ov_mount_count) mounts[0] = Mount { source: repo_path, path: repo_path } mut i: int = 0 while i < ov_mount_count // Expand ~ in mount source for host paths let m: Mount = ov.mounts[i] mounts[i + 1] = Mount { source: paths.expand_home(m.source), path: m.path } i = i + 1 end while return EffectiveConfig { name: name, repo: repo, repo_path: repo_path, image: image, profiles: profiles, mounts: mounts, env_keys: ov.env_keys, env_values: ov.env_values } end merge_with_defaults fn add_project(reg: Registry, p: Project): rg.Result[Registry, string] let n: int = reg.projects.length() mut i: int = 0 while i < n if reg.projects[i].name == p.name return @Result[Registry, string].Err("project already exists: " + p.name) end if i = i + 1 end while mut new_projects: [Project] = new [Project](n + 1) mut k: int = 0 while k < n new_projects[k] = reg.projects[k] k = k + 1 end while new_projects[n] = p return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end add_project fn update_last_sync(reg: Registry, name: string, ts: string): rg.Result[Registry, string] let n: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < n if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 return @Result[Registry, string].Err("project not in registry: " + name) end if mut new_projects: [Project] = new [Project](n) mut k: int = 0 while k < n if k == found let old: Project = reg.projects[k] new_projects[k] = Project { name: old.name, repo: old.repo, image: old.image, profiles: old.profiles, created: old.created, last_sync: ts, backup: old.backup } else new_projects[k] = reg.projects[k] end if k = k + 1 end while return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end update_last_sync // Build a new Registry from an existing one plus a replacement projects list. // Single source of truth for "preserve all top-level Registry fields when // rewriting projects" — keeps fields from drifting silently across the // add/update/remove call sites. fn with_projects(reg: Registry, new_projects: [Project]): Registry return Registry { schema: reg.schema, host: reg.host, output: reg.output, defaults: reg.defaults, projects: new_projects } end with_projects fn remove_project(reg: Registry, name: string): rg.Result[Registry, string] let n: int = reg.projects.length() mut found: int = -1 mut i: int = 0 while i < n if reg.projects[i].name == name found = i end if i = i + 1 end while if found < 0 return @Result[Registry, string].Err("project not in registry: " + name) end if mut new_projects: [Project] = new [Project](n - 1) mut k: int = 0 mut j: int = 0 while k < n if k != found new_projects[j] = reg.projects[k] j = j + 1 end if k = k + 1 end while return @Result[Registry, string].Ok(with_projects(reg, new_projects)) end remove_project fn registry_path(home_dir: string): string let cfg_dir: string = paths.join(home_dir, ".config/repoman") return paths.join(cfg_dir, "repoman.toml") end registry_path fn default_registry(home_dir: string): Registry let repos_root: string = paths.join(home_dir, "repos") let logdir: string = paths.join(home_dir, ".local/state/repoman") return Registry { schema: 3, host: Host { lan_ip: "" }, output: "quiet", defaults: Defaults { repos_root: repos_root, backup_root: "/nfs/repos", logdir: logdir, incus_project: "repoman", default_image: "images:ubuntu/26.04/cloud", profiles: ["default", "claude-share"] }, projects: new [Project](0) } end default_registry fn load_or_init(home_dir: string): rg.Result[Registry, string] let cfg_path: string = registry_path(home_dir) let cfg_dir: string = iopath.dirname(cfg_path) // Ensure ~/.config/repoman/ exists if not iodir.dir_exists(cfg_dir) if not iodir.create_dir_all(cfg_dir) return @Result[Registry, string].Err("cannot create config dir: " + cfg_dir) end if end if if iofile.fileExists(cfg_path) let contents: string = iofile.readFile(cfg_path) return parse_registry(contents) end if // Init: write default registry. let reg: Registry = default_registry(home_dir) let saved_r = save(reg, cfg_path) if rg.is_err(saved_r) return @Result[Registry, string].Err(rg.unwrap_err(saved_r)) end if return @Result[Registry, string].Ok(reg) end load_or_init // Atomic write: writeFile(.tmp) → fsync(.tmp) → rename(.tmp, target). // If any step fails, returns Err naming the failing step. fn save(reg: Registry, cfg_path: string): rg.Result[bool, string] let serialized: string = serialize_registry(reg) let tmp_path: string = cfg_path + ".tmp" if not iofile.writeFile(tmp_path, serialized) return @Result[bool, string].Err("write failed: " + tmp_path) end if if not iofile.fsync(tmp_path) // Best effort: clean up tmp let _d: bool = iofile.deleteFile(tmp_path) return @Result[bool, string].Err("fsync failed: " + tmp_path) end if if not iofile.rename(tmp_path, cfg_path) let _d: bool = iofile.deleteFile(tmp_path) return @Result[bool, string].Err("rename failed: " + tmp_path + " → " + cfg_path) end if return @Result[bool, string].Ok(true) end save end module