|
| 1 | +package com.acltabontabon.launchpad.config; |
| 2 | + |
| 3 | +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
| 4 | +import com.fasterxml.jackson.annotation.JsonInclude; |
| 5 | +import com.fasterxml.jackson.databind.ObjectMapper; |
| 6 | +import com.fasterxml.jackson.databind.SerializationFeature; |
| 7 | +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; |
| 8 | +import java.io.IOException; |
| 9 | +import java.io.UncheckedIOException; |
| 10 | +import java.nio.file.Files; |
| 11 | +import java.nio.file.Path; |
| 12 | +import java.util.ArrayList; |
| 13 | +import java.util.LinkedHashSet; |
| 14 | +import java.util.List; |
| 15 | +import java.util.function.Function; |
| 16 | +import java.util.concurrent.atomic.AtomicReference; |
| 17 | +import org.slf4j.Logger; |
| 18 | +import org.slf4j.LoggerFactory; |
| 19 | +import org.springframework.stereotype.Component; |
| 20 | + |
| 21 | +/** |
| 22 | + * User-global workspace configuration, persisted to {@code ~/.launchpad/config.yml}. |
| 23 | + * The single source of truth for which directories Launchpad scans for projects: |
| 24 | + * the dev roots, an ignore list, the scan depth, and a git-only detection gate. |
| 25 | + * Consumed today by the TUI discovery walk ({@code ProjectDiscovery}) and live |
| 26 | + * search; the UI (#137/#129), CLI (#124), and MCP layers read and write the same |
| 27 | + * service. |
| 28 | + * <p> |
| 29 | + * Schema: |
| 30 | + * <pre> |
| 31 | + * workspace: |
| 32 | + * roots: # when present, REPLACES the built-in defaults (even when empty) |
| 33 | + * - ~/Workspace |
| 34 | + * - ~/clients |
| 35 | + * additionalRoots: # appended to defaults only when roots is NOT set |
| 36 | + * - /work/projects |
| 37 | + * ignored: |
| 38 | + * - ~/dev/legacy-vendor-lib |
| 39 | + * depth: 3 # clamped 1-5, default 3 |
| 40 | + * detectGitOnly: true |
| 41 | + * </pre> |
| 42 | + * {@code ~} and {@code ~/<subpath>} expand to absolute paths on read. An absent |
| 43 | + * file means "use defaults" (no error); a parse failure warns and falls back to |
| 44 | + * defaults, surfaced via {@link #lastLoadError()} so the TUI can show a warning |
| 45 | + * instead of silently behaving as if the file were empty. |
| 46 | + * <p> |
| 47 | + * Thread-safe via copy-on-write through an {@link AtomicReference}; writes are |
| 48 | + * serialized through {@link #mutate}, mirroring {@link ProjectRegistry}. |
| 49 | + */ |
| 50 | +@Component |
| 51 | +public class WorkspaceConfigService { |
| 52 | + |
| 53 | + private static final Logger log = LoggerFactory.getLogger(WorkspaceConfigService.class); |
| 54 | + |
| 55 | + /** |
| 56 | + * Built-in dev-root names relative to {@code $HOME}. Stored and seeded as |
| 57 | + * {@code ~}-prefixed specs so a config written from defaults stays portable. |
| 58 | + */ |
| 59 | + static final List<String> DEFAULT_ROOT_NAMES = |
| 60 | + List.of("Workspace", "workspace", "code", "Code", "dev", "Developer", |
| 61 | + "src", "Projects", "projects", "repos", "git"); |
| 62 | + |
| 63 | + static final int DEFAULT_DEPTH = 3; |
| 64 | + static final int MIN_DEPTH = 1; |
| 65 | + static final int MAX_DEPTH = 5; |
| 66 | + static final boolean DEFAULT_DETECT_GIT_ONLY = true; |
| 67 | + |
| 68 | + /** Empty (all-defaults) state: roots/additionalRoots null = "use defaults". */ |
| 69 | + private static final Workspace EMPTY = new Workspace(null, null, null, null, null); |
| 70 | + |
| 71 | + @JsonIgnoreProperties(ignoreUnknown = true) |
| 72 | + record Document(Workspace workspace) { |
| 73 | + Document { |
| 74 | + workspace = workspace == null ? EMPTY : workspace; |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + @JsonIgnoreProperties(ignoreUnknown = true) |
| 79 | + record Workspace( |
| 80 | + List<String> roots, |
| 81 | + List<String> additionalRoots, |
| 82 | + List<String> ignored, |
| 83 | + Integer depth, |
| 84 | + Boolean detectGitOnly |
| 85 | + ) { |
| 86 | + Workspace { |
| 87 | + // Deliberately do NOT coerce roots/additionalRoots: the replace-vs-append |
| 88 | + // rule depends on distinguishing "unset" (null) from "explicitly empty" |
| 89 | + // (an empty list, meaning "no roots at all"). Only ignored is normalized. |
| 90 | + ignored = ignored == null ? List.of() : List.copyOf(ignored); |
| 91 | + } |
| 92 | + |
| 93 | + Workspace withRoots(List<String> r) { |
| 94 | + return new Workspace(r, additionalRoots, ignored, depth, detectGitOnly); |
| 95 | + } |
| 96 | + |
| 97 | + Workspace withIgnored(List<String> i) { |
| 98 | + return new Workspace(roots, additionalRoots, i, depth, detectGitOnly); |
| 99 | + } |
| 100 | + |
| 101 | + Workspace withDepth(Integer d) { |
| 102 | + return new Workspace(roots, additionalRoots, ignored, d, detectGitOnly); |
| 103 | + } |
| 104 | + |
| 105 | + Workspace withDetectGitOnly(Boolean g) { |
| 106 | + return new Workspace(roots, additionalRoots, ignored, depth, g); |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + private final Path configFile; |
| 111 | + private final ObjectMapper yaml = new ObjectMapper(new YAMLFactory()) |
| 112 | + .setSerializationInclusion(JsonInclude.Include.NON_NULL) |
| 113 | + .enable(SerializationFeature.INDENT_OUTPUT); |
| 114 | + private final AtomicReference<Workspace> current; |
| 115 | + private final AtomicReference<String> lastLoadError = new AtomicReference<>(); |
| 116 | + |
| 117 | + public WorkspaceConfigService() { |
| 118 | + this(Path.of(System.getProperty("user.home"), ".launchpad", "config.yml")); |
| 119 | + } |
| 120 | + |
| 121 | + /** Construct against a custom config path (used by tests and embedders). */ |
| 122 | + public WorkspaceConfigService(Path configFile) { |
| 123 | + this.configFile = configFile; |
| 124 | + this.current = new AtomicReference<>(loadFromDisk()); |
| 125 | + } |
| 126 | + |
| 127 | + // ---- Resolved read API (what the scanner consumes) ---------------------- |
| 128 | + |
| 129 | + /** |
| 130 | + * Effective scan roots as absolute, normalized paths. Applies the replace |
| 131 | + * (roots set) / append (only additionalRoots set) rule, expands {@code ~}, |
| 132 | + * and de-dupes order-preserving. Path existence is NOT filtered here - |
| 133 | + * callers decide what to do with a configured-but-missing root. |
| 134 | + */ |
| 135 | + public List<Path> resolvedRoots() { |
| 136 | + var ws = current.get(); |
| 137 | + List<String> effective; |
| 138 | + if (ws.roots() != null) { |
| 139 | + effective = ws.roots(); |
| 140 | + } else { |
| 141 | + effective = new ArrayList<>(defaultRootSpecs()); |
| 142 | + if (ws.additionalRoots() != null) { |
| 143 | + effective.addAll(ws.additionalRoots()); |
| 144 | + } |
| 145 | + } |
| 146 | + return expandDistinct(effective); |
| 147 | + } |
| 148 | + |
| 149 | + /** Ignored paths as absolute, normalized paths ({@code ~} expanded). */ |
| 150 | + public List<Path> ignoredPaths() { |
| 151 | + return expandDistinct(current.get().ignored()); |
| 152 | + } |
| 153 | + |
| 154 | + /** Scan depth, clamped to {@value #MIN_DEPTH}..{@value #MAX_DEPTH} (default {@value #DEFAULT_DEPTH}). */ |
| 155 | + public int depth() { |
| 156 | + var d = current.get().depth(); |
| 157 | + return clampDepth(d == null ? DEFAULT_DEPTH : d); |
| 158 | + } |
| 159 | + |
| 160 | + /** Whether discovery should only accept directories that are also git repositories. */ |
| 161 | + public boolean detectGitOnly() { |
| 162 | + var g = current.get().detectGitOnly(); |
| 163 | + return g == null ? DEFAULT_DETECT_GIT_ONLY : g; |
| 164 | + } |
| 165 | + |
| 166 | + /** |
| 167 | + * Non-null reason string when the last load failed (file present but |
| 168 | + * unreadable/unparseable), otherwise null. Surfaced by the TUI so a broken |
| 169 | + * config warns instead of silently behaving as defaults. |
| 170 | + */ |
| 171 | + public String lastLoadError() { |
| 172 | + return lastLoadError.get(); |
| 173 | + } |
| 174 | + |
| 175 | + // ---- Raw read API (what the settings UI round-trips) -------------------- |
| 176 | + |
| 177 | + /** Raw {@code roots} entries as written (unexpanded), or empty when unset. */ |
| 178 | + public List<String> rawRoots() { |
| 179 | + var r = current.get().roots(); |
| 180 | + return r == null ? List.of() : r; |
| 181 | + } |
| 182 | + |
| 183 | + /** Raw {@code additionalRoots} entries as written (unexpanded), or empty when unset. */ |
| 184 | + public List<String> rawAdditionalRoots() { |
| 185 | + var a = current.get().additionalRoots(); |
| 186 | + return a == null ? List.of() : a; |
| 187 | + } |
| 188 | + |
| 189 | + /** Raw {@code ignored} entries as written (unexpanded). */ |
| 190 | + public List<String> rawIgnored() { |
| 191 | + return current.get().ignored(); |
| 192 | + } |
| 193 | + |
| 194 | + /** True when {@code roots} is set explicitly (replace mode), false when defaults apply (append mode). */ |
| 195 | + public boolean rootsExplicitlySet() { |
| 196 | + return current.get().roots() != null; |
| 197 | + } |
| 198 | + |
| 199 | + /** Raw {@code depth} value as written, or null when unset. */ |
| 200 | + public Integer rawDepth() { |
| 201 | + return current.get().depth(); |
| 202 | + } |
| 203 | + |
| 204 | + /** Raw {@code detectGitOnly} value as written, or null when unset. */ |
| 205 | + public Boolean rawDetectGitOnly() { |
| 206 | + return current.get().detectGitOnly(); |
| 207 | + } |
| 208 | + |
| 209 | + // ---- Mutators (persist via mutate) -------------------------------------- |
| 210 | + |
| 211 | + /** |
| 212 | + * Add a root. Adding implies explicit/replace mode, so when {@code roots} |
| 213 | + * was unset it is first seeded from the built-in defaults. The raw spec |
| 214 | + * (e.g. {@code ~/clients}) is stored unexpanded for portability. |
| 215 | + */ |
| 216 | + public void addRoot(String raw) { |
| 217 | + var v = trim(raw); |
| 218 | + if (v.isBlank()) return; |
| 219 | + mutate(ws -> { |
| 220 | + var roots = ws.roots() == null |
| 221 | + ? new ArrayList<>(defaultRootSpecs()) |
| 222 | + : new ArrayList<>(ws.roots()); |
| 223 | + if (roots.contains(v)) return ws; |
| 224 | + roots.add(v); |
| 225 | + return ws.withRoots(roots); |
| 226 | + }); |
| 227 | + } |
| 228 | + |
| 229 | + /** Remove a root by raw spec. Returns whether anything was removed. */ |
| 230 | + public boolean removeRoot(String raw) { |
| 231 | + var v = trim(raw); |
| 232 | + if (v.isBlank()) return false; |
| 233 | + var ws = current.get(); |
| 234 | + var base = ws.roots() == null ? defaultRootSpecs() : ws.roots(); |
| 235 | + if (!base.contains(v)) return false; |
| 236 | + var next = new ArrayList<>(base); |
| 237 | + next.remove(v); |
| 238 | + mutate(w -> w.withRoots(next)); |
| 239 | + return true; |
| 240 | + } |
| 241 | + |
| 242 | + /** Add an ignored path (raw spec stored unexpanded). */ |
| 243 | + public void addIgnored(String raw) { |
| 244 | + var v = trim(raw); |
| 245 | + if (v.isBlank()) return; |
| 246 | + mutate(ws -> { |
| 247 | + if (ws.ignored().contains(v)) return ws; |
| 248 | + var next = new ArrayList<>(ws.ignored()); |
| 249 | + next.add(v); |
| 250 | + return ws.withIgnored(next); |
| 251 | + }); |
| 252 | + } |
| 253 | + |
| 254 | + /** Remove an ignored path by raw spec. Returns whether anything was removed. */ |
| 255 | + public boolean removeIgnored(String raw) { |
| 256 | + var v = trim(raw); |
| 257 | + if (v.isBlank()) return false; |
| 258 | + if (!current.get().ignored().contains(v)) return false; |
| 259 | + mutate(ws -> ws.withIgnored( |
| 260 | + ws.ignored().stream().filter(x -> !x.equals(v)).toList())); |
| 261 | + return true; |
| 262 | + } |
| 263 | + |
| 264 | + /** Set the scan depth (stored clamped to {@value #MIN_DEPTH}..{@value #MAX_DEPTH}). */ |
| 265 | + public void setDepth(int depth) { |
| 266 | + var clamped = clampDepth(depth); |
| 267 | + mutate(ws -> ws.withDepth(clamped)); |
| 268 | + } |
| 269 | + |
| 270 | + /** Set the git-only detection gate. */ |
| 271 | + public void setDetectGitOnly(boolean detectGitOnly) { |
| 272 | + mutate(ws -> ws.withDetectGitOnly(detectGitOnly)); |
| 273 | + } |
| 274 | + |
| 275 | + // ---- Internals ---------------------------------------------------------- |
| 276 | + |
| 277 | + private static List<String> defaultRootSpecs() { |
| 278 | + return DEFAULT_ROOT_NAMES.stream().map(n -> "~/" + n).toList(); |
| 279 | + } |
| 280 | + |
| 281 | + private static List<Path> expandDistinct(List<String> specs) { |
| 282 | + var out = new LinkedHashSet<Path>(); |
| 283 | + for (var spec : specs) { |
| 284 | + if (spec == null || spec.isBlank()) continue; |
| 285 | + out.add(expand(spec)); |
| 286 | + } |
| 287 | + return List.copyOf(out); |
| 288 | + } |
| 289 | + |
| 290 | + /** Expand {@code ~} / {@code ~/<subpath>} against {@code $HOME}; absolutize and normalize. */ |
| 291 | + private static Path expand(String raw) { |
| 292 | + var t = raw.trim(); |
| 293 | + var home = System.getProperty("user.home"); |
| 294 | + Path p; |
| 295 | + if (t.equals("~")) { |
| 296 | + p = Path.of(home); |
| 297 | + } else if (t.startsWith("~/")) { |
| 298 | + p = Path.of(home, t.substring(2)); |
| 299 | + } else { |
| 300 | + p = Path.of(t); |
| 301 | + } |
| 302 | + return p.toAbsolutePath().normalize(); |
| 303 | + } |
| 304 | + |
| 305 | + private static int clampDepth(int d) { |
| 306 | + return Math.max(MIN_DEPTH, Math.min(MAX_DEPTH, d)); |
| 307 | + } |
| 308 | + |
| 309 | + private static String trim(String raw) { |
| 310 | + return raw == null ? "" : raw.trim(); |
| 311 | + } |
| 312 | + |
| 313 | + private void mutate(Function<Workspace, Workspace> fn) { |
| 314 | + synchronized (current) { |
| 315 | + var next = fn.apply(current.get()); |
| 316 | + current.set(next); |
| 317 | + writeToDisk(next); |
| 318 | + } |
| 319 | + } |
| 320 | + |
| 321 | + private Workspace loadFromDisk() { |
| 322 | + if (!Files.isRegularFile(configFile)) { |
| 323 | + lastLoadError.set(null); |
| 324 | + return EMPTY; |
| 325 | + } |
| 326 | + try { |
| 327 | + var doc = yaml.readValue(configFile.toFile(), Document.class); |
| 328 | + lastLoadError.set(null); |
| 329 | + return doc.workspace(); |
| 330 | + } catch (IOException | RuntimeException e) { |
| 331 | + var summary = e.getClass().getSimpleName() + ": " + e.getMessage(); |
| 332 | + lastLoadError.set("Failed to read " + configFile + " - " + summary); |
| 333 | + log.warn("Failed to read workspace config at {} - using defaults. " |
| 334 | + + "If running under GraalVM native, this is usually a missing reflection hint.", |
| 335 | + configFile, e); |
| 336 | + return EMPTY; |
| 337 | + } |
| 338 | + } |
| 339 | + |
| 340 | + private void writeToDisk(Workspace ws) { |
| 341 | + try { |
| 342 | + Files.createDirectories(configFile.getParent()); |
| 343 | + yaml.writeValue(configFile.toFile(), new Document(ws)); |
| 344 | + } catch (IOException e) { |
| 345 | + throw new UncheckedIOException("Failed to write " + configFile, e); |
| 346 | + } |
| 347 | + } |
| 348 | +} |
0 commit comments