PreferencesService.java
package com.taxonomy.preferences;
import com.taxonomy.preferences.storage.PreferencesCommit;
import com.taxonomy.preferences.storage.PreferencesGitRepository;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Service that manages runtime application preferences backed by a dedicated JGit repository.
*
* <p>On startup, preferences are loaded from the HEAD of the {@code main} branch in the
* preferences Git repository. If no commit exists yet, all values are initialised from
* {@code application.properties} defaults and committed as the first entry.
*
* <p>Every {@link #update(Map)} call merges the provided changes into the current preferences,
* serialises them to JSON, and creates a new Git commit — giving a full audit trail of all
* preference changes over time.
*
* <p>The preferences Git repository uses project name {@code "taxonomy-preferences"}, which
* is completely separate from the Architecture DSL repository ({@code "taxonomy-dsl"}).
*/
@Service
public class PreferencesService {
private static final Logger log = LoggerFactory.getLogger(PreferencesService.class);
private static final String TOKEN_MASK_PREFIX = "****";
private final PreferencesGitRepository gitRepository;
private final ObjectMapper objectMapper;
// In-memory cache of the current preferences
private final Map<String, Object> cache = new ConcurrentHashMap<>();
// ── Default values loaded from application.properties ─────────────────────
@Value("${taxonomy.llm.rpm:5}")
private int defaultLlmRpm;
@Value("${taxonomy.llm.timeout-seconds:30}")
private int defaultLlmTimeoutSeconds;
@Value("${taxonomy.rate-limit.per-minute:10}")
private int defaultRateLimitPerMinute;
@Value("${taxonomy.analysis.min-score:70}")
private int defaultAnalysisMinScore;
@Value("${taxonomy.dsl.default-branch:draft}")
private String defaultDslDefaultBranch;
@Value("${taxonomy.dsl.project-name:Taxonomy Architecture}")
private String defaultDslProjectName;
@Value("${taxonomy.dsl.auto-save-interval:0}")
private int defaultDslAutoSaveInterval;
@Value("${taxonomy.dsl.remote-url:}")
private String defaultDslRemoteUrl;
@Value("${taxonomy.dsl.remote-token:}")
private String defaultDslRemoteToken;
@Value("${taxonomy.dsl.remote-push-on-commit:false}")
private boolean defaultDslRemotePushOnCommit;
@Value("${taxonomy.limits.max-business-text:5000}")
private int defaultMaxBusinessText;
@Value("${taxonomy.limits.max-architecture-nodes:50}")
private int defaultMaxArchitectureNodes;
@Value("${taxonomy.limits.max-export-nodes:200}")
private int defaultMaxExportNodes;
@Value("${taxonomy.diagram.policy:defaultImpact}")
private String defaultDiagramPolicy;
public PreferencesService(PreferencesGitRepository gitRepository, ObjectMapper objectMapper) {
this.gitRepository = gitRepository;
this.objectMapper = objectMapper;
}
/**
* On startup: load preferences from the JGit repository, or initialise from defaults.
*/
@PostConstruct
public void init() {
try {
String json = gitRepository.readHead();
if (json != null) {
@SuppressWarnings("unchecked")
Map<String, Object> loaded = objectMapper.readValue(json, Map.class);
cache.putAll(loaded);
log.info("Preferences loaded from JGit repository ({} entries)", cache.size());
} else {
// No commits yet — initialise from property defaults and commit
cache.putAll(buildDefaults());
String initialJson = objectMapper.writeValueAsString(cache);
gitRepository.commit(initialJson, "system", "Initial preferences from application.properties");
log.info("Preferences initialised from defaults and committed to JGit");
}
} catch (IOException e) {
log.warn("Could not load preferences from JGit, using defaults: {}", e.getMessage());
cache.putAll(buildDefaults());
}
}
/**
* Returns all current preferences. The {@code dsl.remote.token} value is masked.
*
* @return an unmodifiable copy of the preferences map with the token masked
*/
public Map<String, Object> getAll() {
Map<String, Object> result = new LinkedHashMap<>(cache);
maskToken(result);
return Collections.unmodifiableMap(result);
}
/**
* Returns the raw (unmasked) value for the given key.
*
* @param key the preference key
* @return the value, or {@code null} if not set
*/
public Object get(String key) {
return cache.get(key);
}
/**
* Returns the integer value for the given key, or the default if not present or not a number.
*/
public int getInt(String key, int defaultValue) {
Object val = cache.get(key);
if (val instanceof Number n) return n.intValue();
if (val instanceof String s) {
try { return Integer.parseInt(s); } catch (NumberFormatException ignored) {}
}
return defaultValue;
}
/**
* Returns the string value for the given key, or the default if not present.
*/
public String getString(String key, String defaultValue) {
Object val = cache.get(key);
return val != null ? String.valueOf(val) : defaultValue;
}
/**
* Returns the boolean value for the given key, or the default if not present.
*/
public boolean getBoolean(String key, boolean defaultValue) {
Object val = cache.get(key);
if (val instanceof Boolean b) return b;
if (val instanceof String s) return Boolean.parseBoolean(s);
return defaultValue;
}
/**
* Merge the provided changes into the current preferences, commit to JGit, and update
* the in-memory cache. Each call creates a new Git commit with the full preferences JSON.
*
* @param changes partial map of settings to update
* @param author the user making the change (for the Git commit author)
* @throws IOException if the JGit commit fails
*/
public void update(Map<String, Object> changes, String author) throws IOException {
cache.putAll(changes);
String json = objectMapper.writeValueAsString(cache);
String commitMsg = "Preferences updated: " + String.join(", ", changes.keySet());
gitRepository.commit(json, author, commitMsg);
log.info("Preferences updated by '{}': {}", author, changes.keySet());
}
/**
* Resets all preferences to their default values (from application.properties),
* commits to JGit, and updates the in-memory cache.
*
* @param author the user requesting the reset
* @throws IOException if the JGit commit fails
*/
public void resetToDefaults(String author) throws IOException {
Map<String, Object> defaults = buildDefaults();
cache.clear();
cache.putAll(defaults);
String json = objectMapper.writeValueAsString(cache);
gitRepository.commit(json, author, "Preferences reset to defaults");
log.info("Preferences reset to defaults by '{}'", author);
}
/**
* Returns the commit history of the preferences repository, newest first.
*/
public List<PreferencesCommit> getHistory() throws IOException {
return gitRepository.getHistory();
}
// ── Private helpers ────────────────────────────────────────────────────────
private Map<String, Object> buildDefaults() {
Map<String, Object> defaults = new LinkedHashMap<>();
// LLM Configuration
defaults.put("llm.rpm", defaultLlmRpm);
defaults.put("llm.timeout.seconds", defaultLlmTimeoutSeconds);
defaults.put("rate-limit.per-minute", defaultRateLimitPerMinute);
defaults.put("analysis.min-relevance-score", defaultAnalysisMinScore);
// JGit / DSL Configuration
defaults.put("dsl.default-branch", defaultDslDefaultBranch);
defaults.put("dsl.project-name", defaultDslProjectName);
defaults.put("dsl.auto-save.interval-seconds", defaultDslAutoSaveInterval);
defaults.put("dsl.remote.url", defaultDslRemoteUrl);
defaults.put("dsl.remote.token", defaultDslRemoteToken);
defaults.put("dsl.remote.push-on-commit", defaultDslRemotePushOnCommit);
// Size Limits
defaults.put("limits.max-business-text", defaultMaxBusinessText);
defaults.put("limits.max-architecture-nodes", defaultMaxArchitectureNodes);
defaults.put("limits.max-export-nodes", defaultMaxExportNodes);
// Diagram Configuration
defaults.put("diagram.policy", defaultDiagramPolicy);
return defaults;
}
private void maskToken(Map<String, Object> prefs) {
Object token = prefs.get("dsl.remote.token");
if (token instanceof String s && !s.isEmpty()) {
int showChars = Math.min(4, s.length());
prefs.put("dsl.remote.token",
TOKEN_MASK_PREFIX + s.substring(s.length() - showChars));
}
}
}