/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/profile.reef Authors: Chris Tusa License: Description: Profile library: vendor/user shadowing and ${HOST_LAN_IP}/${USER}/${HOME} substitution ******************************************************************************/ module profile import core.str import core.result as rg import core.error as error import core.convert as convert import io.file as iofile import io.dir as iodir import sys.env import paths import incus export type ProfileEntry type HostFacts fn vendor_dir(): string fn user_dir(home_dir: string): string fn render(yaml: string, host: HostFacts): string fn lookup(name: string, home_dir: string): rg.Result[string, string] fn list_all(home_dir: string): [ProfileEntry] fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] fn remove_profile(name: string): rg.Result[bool, string] fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] end export // One entry from the profile library, populated by list_all. type ProfileEntry = struct name: string // e.g., "claude-share" source: string // "user", "vendor", or "user (shadows vendor)" file_path: string // resolved file path installed: bool // present in incus state end ProfileEntry // Substitution context for templated profile YAML. type HostFacts = struct lan_ip: string user: string home: string end HostFacts // (function bodies follow in subsequent tasks) fn vendor_dir(): string return "/usr/local/share/repoman/profiles" end vendor_dir fn user_dir(home_dir: string): string let suffix: string = ".config/repoman/profiles.d" if str.is_empty(home_dir) return "/" + suffix end if return paths.join(home_dir, suffix) end user_dir fn render(yaml: string, host: HostFacts): string let s1: string = str.replace(yaml, "\${HOST_LAN_IP}", host.lan_ip) let s2: string = str.replace(s1, "\${USER}", host.user) let s3: string = str.replace(s2, "\${HOME}", host.home) return s3 end render fn lookup(name: string, home_dir: string): rg.Result[string, string] let user_path: string = paths.join(user_dir(home_dir), name + ".yml") if iofile.fileExists(user_path) return @Result[string, string].Ok(user_path) end if let vendor_path: string = paths.join(vendor_dir(), name + ".yml") if iofile.fileExists(vendor_path) return @Result[string, string].Ok(vendor_path) end if return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")") end lookup fn list_all(home_dir: string): [ProfileEntry] // list_dir returns the directory's entries as an array (Result-wrapped); // a missing/unreadable dir is treated as empty. Profile libraries are // not expected to be enormous. let u_dir: string = user_dir(home_dir) mut user_buf: [string] = new [string](0) if iodir.dir_exists(u_dir) let ur = iodir.list_dir(u_dir) if rg.is_ok(ur) user_buf = rg.unwrap_ok(ur) end if end if let user_count: int = user_buf.length() let v_dir: string = vendor_dir() mut vendor_buf: [string] = new [string](0) if iodir.dir_exists(v_dir) let vr = iodir.list_dir(v_dir) if rg.is_ok(vr) vendor_buf = rg.unwrap_ok(vr) end if end if let vendor_count: int = vendor_buf.length() // Pre-allocate result buffer at worst-case size. let cap: int = user_count + vendor_count mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap) mut e_count: int = 0 // Pass 1: add all user-dir *.yml files mut i: int = 0 while i < user_count let fname: string = user_buf[i] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) let path: string = paths.join(u_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "user", file_path: path, installed: installed } e_count = e_count + 1 end if i = i + 1 end while // Pass 2: add vendor-dir *.yml files NOT already present (so user wins) mut j: int = 0 while j < vendor_count let fname: string = vendor_buf[j] if str.ends_with(fname, ".yml") let name: string = str.substring(fname, 0, str.length(fname) - 4) // Check if name already in entries_buf mut shadowed: bool = false mut k: int = 0 while k < e_count if entries_buf[k].name == name shadowed = true // Update the existing user entry's source label to indicate shadow entries_buf[k] = ProfileEntry { name: entries_buf[k].name, source: "user (shadows vendor)", file_path: entries_buf[k].file_path, installed: entries_buf[k].installed } end if k = k + 1 end while if not shadowed let path: string = paths.join(v_dir, fname) let installed_r = incus.profile_exists("default", name) mut installed: bool = false if rg.is_ok(installed_r) installed = rg.unwrap_ok(installed_r) end if entries_buf[e_count] = ProfileEntry { name: name, source: "vendor", file_path: path, installed: installed } e_count = e_count + 1 end if end if j = j + 1 end while // Compact to actual size mut entries: [ProfileEntry] = new [ProfileEntry](e_count) mut m: int = 0 while m < e_count entries[m] = entries_buf[m] m = m + 1 end while return entries end list_all fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string] // Resolve the file (user or vendor) let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[bool, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) // Read the file let yaml_r = iofile.readFile(path) if rg.is_err(yaml_r) return @Result[bool, string].Err("unreadable profile file: " + path + ": " + error.error_message(rg.unwrap_err(yaml_r))) end if let yaml: string = rg.unwrap_ok(yaml_r) if str.length(yaml) == 0 return @Result[bool, string].Err("empty profile file: " + path) end if // Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint if str.contains(yaml, "\${HOST_LAN_IP}") and str.length(host.lan_ip) == 0 return @Result[bool, string].Err("profile " + name + " requires \${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.") end if let rendered: string = render(yaml, host) // Apply via incus return incus.profile_create_or_edit("default", name, rendered) end install fn remove_profile(name: string): rg.Result[bool, string] return incus.delete_profile("default", name) end remove_profile fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml_r = iofile.readFile(path) if rg.is_err(yaml_r) return @Result[string, string].Err("cannot read profile file: " + path + ": " + error.error_message(rg.unwrap_err(yaml_r))) end if let rendered: string = render(rg.unwrap_ok(yaml_r), host) return @Result[string, string].Ok(rendered) end show fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string] // Render the file let path_r = lookup(name, home_dir) if rg.is_err(path_r) return @Result[string, string].Err(rg.unwrap_err(path_r)) end if let path: string = rg.unwrap_ok(path_r) let yaml_r = iofile.readFile(path) if rg.is_err(yaml_r) return @Result[string, string].Err("cannot read profile file: " + path + ": " + error.error_message(rg.unwrap_err(yaml_r))) end if let rendered: string = render(rg.unwrap_ok(yaml_r), host) // Fetch incus state let incus_r = incus.profile_get("default", name) if rg.is_err(incus_r) // Profile not installed — diff shows the rendered file as "would install" let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered return @Result[string, string].Ok(msg) end if let live: string = rg.unwrap_ok(incus_r) if rendered == live return @Result[string, string].Ok("(no drift)") end if // Simple side-by-side: file content marked '-- file --', incus content '-- incus --'. let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n" let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live return @Result[string, string].Ok(header + body) end diff end module