/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (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_generic as rg 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] // Buffers for filename lists. Cap at 256 per dir; profile libraries // are not expected to be enormous. let max_files: int = 256 mut user_buf: [string] = new [string](max_files) mut vendor_buf: [string] = new [string](max_files) let u_dir: string = user_dir(home_dir) mut user_count: int = 0 if iodir.dir_exists(u_dir) user_count = iodir.list_dir(u_dir, user_buf, max_files) end if let v_dir: string = vendor_dir() mut vendor_count: int = 0 if iodir.dir_exists(v_dir) vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files) end if // 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: string = iofile.readFile(path) if str.length(yaml) == 0 return @Result[bool, string].Err("empty or unreadable 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: string = iofile.readFile(path) let rendered: string = render(yaml, 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: string = iofile.readFile(path) let rendered: string = render(yaml, 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