8Provide oc_diagnostics configuration management tools for the OCT CLI.
12- Load, validate, and modify ``oc_diagnostics/debug_config.json``.
13- List configured domains with their levels and colors.
14- Update domain debug levels in-place.
15- Report validation warnings without importing oc_diagnostics at runtime
16 (preserving the dev-time/runtime architectural boundary).
28- Must not import ``oc_diagnostics`` — operates purely on the JSON config file.
29- Must preserve JSON formatting and key order when writing back.
30- Level values must be integers 0-4 (matching ``oc_diagnostics.DEBUG_LEVELS``).
34- oct.core.option_c_dir (debug_config.json path resolution)
38from pathlib
import Path
40from oct.core.option_c_dir
import resolve_debug_config
43VALID_LEVELS = {0, 1, 2, 3, 4}
46CURRENT_SCHEMA_VERSION =
"2.0"
48EXPECTED_GLOBAL_KEYS = {
49 "silent_mode",
"enable_timestamps",
"enable_colors",
"include_filename",
50 "output_mode",
"instrumentation_enabled",
"max_log_files",
"max_log_size_mb",
51 "instrumentation_allow_all",
"mode",
"http_redact_headers",
52 "ui_event_properties",
"structural_id_prefixes",
"structural_id_exact",
57 """Locate debug_config.json (FS-539: `.option_c/`-first with fallbacks).
59 Delegates to :func:`oct.core.option_c_dir.resolve_debug_config`, which
60 tries ``.option_c/debug_config.json`` first, then
61 ``oc_diagnostics/debug_config.json`` (v0.17+), then
62 ``diagnostics/debug_config.json`` (pre-v0.17).
64 return resolve_debug_config(project_root)
68 """Load and parse debug_config.json. Raises on missing/invalid file."""
70 if not config_path.is_file():
71 raise FileNotFoundError(f
"Config not found: {config_path}")
72 text = config_path.read_text(encoding=
"utf-8")
73 return json.loads(text)
77 """OI-508 / FS-515: return ``(domain_map, is_legacy)`` from a config dict.
79 Accepts both the legacy v1 ``domains:`` layout and the current v2
80 ``DEBUG_LEVELS:`` layout. The returned map is always in the v2 shape
81 (``{name: {"level": int, "color": str}}``) regardless of what the file
82 contains — call sites can read the result uniformly.
84 ``is_legacy`` is True whenever the file used the ``domains:`` key
85 **and** the ``DEBUG_LEVELS:`` key was absent — that is the trigger
86 for the migration hint.
88 if "DEBUG_LEVELS" in config
and isinstance(config[
"DEBUG_LEVELS"], dict):
89 return dict(config[
"DEBUG_LEVELS"]),
False
90 legacy = config.get(
"domains")
91 if not isinstance(legacy, dict):
94 for name, entry
in legacy.items():
95 if isinstance(entry, dict):
96 normalised[name] = dict(entry)
97 elif isinstance(entry, int):
99 normalised[name] = {
"level": entry,
"color":
"default"}
101 normalised[name] = {
"level": entry,
"color":
"default"}
102 return normalised,
True
106 """OI-508 / FS-515: rewrite ``config_path`` in-place as a v2 config.
109 * Idempotent — if the file already declares ``_schema_version == "2.0"``
110 and carries ``DEBUG_LEVELS``, nothing is written.
111 * Writes ``<config_path>.bk`` before overwriting the original, so a
112 botched migration is recoverable.
113 * When ``dry_run`` is True, the target file is never touched and no
114 backup is produced; the returned dict is the post-migration shape.
116 Returns the (would-be) post-migration config dict so callers can
117 inspect / pretty-print the result.
119 if not config_path.is_file():
120 raise FileNotFoundError(f
"Config not found: {config_path}")
121 original_text = config_path.read_text(encoding=
"utf-8")
122 config = json.loads(original_text)
123 if not isinstance(config, dict):
124 raise ValueError(
"Config root must be a JSON object")
128 config.get(
"_schema_version") == CURRENT_SCHEMA_VERSION
129 and "DEBUG_LEVELS" in config
136 new_config: dict = {
"_schema_version": CURRENT_SCHEMA_VERSION,
"DEBUG_LEVELS": domain_map}
137 for key, value
in config.items():
138 if key
in (
"_schema_version",
"DEBUG_LEVELS",
"domains"):
140 new_config[key] = value
145 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
146 backup_path.write_text(original_text, encoding=
"utf-8")
147 config_path.write_text(json.dumps(new_config, indent=2) +
"\n", encoding=
"utf-8")
152 """Validate debug_config.json schema. Returns list of warnings/errors."""
153 issues: list[str] = []
156 if not config_path.is_file():
157 issues.append(f
"ERROR: Config file not found: {config_path}")
161 text = config_path.read_text(encoding=
"utf-8")
163 issues.append(f
"ERROR: Cannot read {config_path}: {e}")
167 config = json.loads(text)
168 except json.JSONDecodeError
as e:
169 issues.append(f
"ERROR: Invalid JSON in {config_path}: {e}")
172 if not isinstance(config, dict):
173 issues.append(
"ERROR: Config root must be a JSON object")
177 version = config.get(
"_schema_version")
179 issues.append(
"WARNING: Missing '_schema_version' key")
187 "WARNING: 'domains' key is legacy v1 schema; run 'oct diag migrate-config' "
188 "to upgrade to v2 ('DEBUG_LEVELS')."
190 elif not domain_map
and "DEBUG_LEVELS" not in config
and "domains" not in config:
191 issues.append(
"WARNING: Missing 'DEBUG_LEVELS' key")
192 elif "DEBUG_LEVELS" in config
and not isinstance(config.get(
"DEBUG_LEVELS"), dict):
193 issues.append(
"ERROR: 'DEBUG_LEVELS' must be a JSON object")
194 for name, entry
in domain_map.items():
195 if not isinstance(entry, dict):
196 issues.append(f
"ERROR: Domain '{name}' must be a JSON object")
198 level = entry.get(
"level")
200 issues.append(f
"WARNING: Domain '{name}' missing 'level' key")
201 elif not isinstance(level, int)
or level
not in VALID_LEVELS:
202 issues.append(f
"ERROR: Domain '{name}' level must be 0-4, got: {level}")
205 settings = config.get(
"global_settings")
207 issues.append(
"WARNING: Missing 'global_settings' key")
208 elif not isinstance(settings, dict):
209 issues.append(
"ERROR: 'global_settings' must be a JSON object")
211 output_mode = settings.get(
"output_mode")
212 if output_mode
is not None and output_mode
not in (
"terminal",
"file",
"both",
"json"):
213 issues.append(f
"WARNING: 'output_mode' should be terminal|file|both|json, got: {output_mode!r}")
214 mode = settings.get(
"mode")
215 if mode
is not None and mode
not in (
"dev",
"prod"):
216 issues.append(f
"WARNING: 'mode' should be dev|prod, got: {mode!r}")
222 """Return list of domain info dicts: [{name, level, color}, ...].
224 OI-508 / FS-515: reads via :func:`_get_debug_domain_map` so both v1
225 and v2 configs surface identical output.
230 for name, entry
in domain_map.items():
233 "level": entry.get(
"level",
"?"),
234 "color": entry.get(
"color",
"default"),
244 dry_run: bool =
False,
246 """Update a domain's debug level in debug_config.json.
248 OI-508 / FS-515: writes to whichever key the file actually uses
249 (``DEBUG_LEVELS`` on v2, ``domains`` on legacy v1) so an operator
250 can tweak a level without a full migration first.
252 OI-528: safe-edit semantics. A ``<config_path>.bk`` backup is written
253 before overwriting the original so a bad edit is recoverable via
254 :func:`restore_config`. When ``dry_run`` is True, neither the target
255 nor the backup is touched; the returned dict is the *would-be*
256 post-edit config shape so callers can preview the result.
258 if level
not in VALID_LEVELS:
259 raise ValueError(f
"Level must be 0-4, got: {level}")
262 if not config_path.is_file():
263 raise FileNotFoundError(f
"Config not found: {config_path}")
265 original_text = config_path.read_text(encoding=
"utf-8")
266 config = json.loads(original_text)
267 if "DEBUG_LEVELS" in config
and isinstance(config[
"DEBUG_LEVELS"], dict):
268 target_key =
"DEBUG_LEVELS"
269 elif "domains" in config
and isinstance(config[
"domains"], dict):
270 target_key =
"domains"
272 raise KeyError(
"Config has neither 'DEBUG_LEVELS' nor 'domains' keys")
274 domains = config[target_key]
275 if domain
not in domains:
276 raise KeyError(f
"Domain '{domain}' not found in config. Available: {', '.join(domains)}")
278 entry = domains[domain]
279 if isinstance(entry, dict):
280 entry[
"level"] = level
282 domains[domain] = {
"level": level,
"color":
"default"}
287 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
288 backup_path.write_text(original_text, encoding=
"utf-8")
289 config_path.write_text(json.dumps(config, indent=2) +
"\n", encoding=
"utf-8")
294 """OI-528: restore ``debug_config.json`` from its ``.bk`` backup.
296 Returns the path that was restored. Raises :class:`FileNotFoundError`
297 when no backup exists so the CLI can surface a clean error instead of
301 backup_path = config_path.with_suffix(config_path.suffix +
".bk")
302 if not backup_path.is_file():
303 raise FileNotFoundError(f
"No backup found at {backup_path}")
304 config_path.write_text(backup_path.read_text(encoding=
"utf-8"), encoding=
"utf-8")
dict set_level(Path project_root, str domain, int level, *, bool dry_run=False)
list[dict] list_domains(Path project_root)
list[str] validate_config(Path project_root)
Path restore_config(Path project_root)
dict migrate_config(Path config_path, *, bool dry_run=False)
Path _find_config(Path project_root)
dict load_config(Path project_root)
tuple[dict, bool] _get_debug_domain_map(dict config)