Skip to content

Commit 3c63b60

Browse files
fix: preserve pom.xml formatting in config writer and align write/remove priority
Replace xml.etree.ElementTree with text-based regex manipulation in _write_maven_properties() and _remove_java_build_config(). ElementTree destroys XML comments, mangles namespace declarations (ns0: prefixes), and reformats whitespace. The new approach reads/writes pom.xml as plain text, only touching codeflash.* property lines. Also extracts duplicated key_map to shared _MAVEN_KEY_MAP constant and aligns remove priority to check pom.xml first (matching write order). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 13dae81 commit 3c63b60

4 files changed

Lines changed: 257 additions & 64 deletions

File tree

codeflash/setup/config_writer.py

Lines changed: 91 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -124,47 +124,87 @@ def _write_java_build_config(project_root: Path, config: CodeflashConfig) -> tup
124124
return _write_gradle_properties(gradle_props_path, non_default)
125125

126126

127+
_MAVEN_KEY_MAP: dict[str, str] = {
128+
"module-root": "moduleRoot",
129+
"tests-root": "testsRoot",
130+
"git-remote": "gitRemote",
131+
"disable-telemetry": "disableTelemetry",
132+
"ignore-paths": "ignorePaths",
133+
"formatter-cmds": "formatterCmds",
134+
}
135+
136+
127137
def _write_maven_properties(pom_path: Path, config: dict[str, Any]) -> tuple[bool, str]:
128-
"""Add codeflash.* properties to pom.xml <properties> section."""
129-
import xml.etree.ElementTree as ET
138+
"""Add codeflash.* properties to pom.xml <properties> section.
139+
140+
Uses text-based manipulation to preserve comments, formatting, and namespace declarations.
141+
"""
142+
import re
130143

131144
try:
132-
tree = ET.parse(str(pom_path))
133-
root = tree.getroot()
134-
ns = {"m": "http://maven.apache.org/POM/4.0.0"}
135-
136-
# Find or create <properties>
137-
properties = root.find("m:properties", ns) or root.find("properties")
138-
if properties is None:
139-
properties = ET.SubElement(root, "properties")
140-
141-
# Convert kebab-case keys to camelCase for Maven convention
142-
key_map = {
143-
"module-root": "moduleRoot",
144-
"tests-root": "testsRoot",
145-
"git-remote": "gitRemote",
146-
"disable-telemetry": "disableTelemetry",
147-
"ignore-paths": "ignorePaths",
148-
"formatter-cmds": "formatterCmds",
149-
}
145+
content = pom_path.read_text(encoding="utf-8")
146+
147+
# Remove existing codeflash.* property lines (with surrounding whitespace)
148+
content = re.sub(r"\n[ \t]*<codeflash\.[^>]*>[^<]*</codeflash\.[^>]*>", "", content)
149+
150+
# Detect child indentation from existing properties or fall back to </properties> indent + 4 spaces
151+
props_close = re.search(r"([ \t]*)</properties>", content)
152+
if props_close:
153+
parent_indent = props_close.group(1)
154+
# Try to detect child indent from an existing property element
155+
child_match = re.search(
156+
r"\n([ \t]+)<[a-zA-Z]",
157+
content[content.find("<properties>") : props_close.start()] if "<properties>" in content else "",
158+
)
159+
child_indent = child_match.group(1) if child_match else parent_indent + " "
160+
else:
161+
parent_indent = ""
162+
child_indent = " "
150163

164+
# Build new property lines with detected indentation
165+
new_lines = []
151166
for key, value in config.items():
152-
maven_key = f"codeflash.{key_map.get(key, key)}"
167+
maven_key = f"codeflash.{_MAVEN_KEY_MAP.get(key, key)}"
153168
if isinstance(value, list):
154169
value = ",".join(str(v) for v in value)
155170
elif isinstance(value, bool):
156171
value = str(value).lower()
157172
else:
158173
value = str(value)
159-
160-
existing = properties.find(maven_key)
161-
if existing is None:
162-
elem = ET.SubElement(properties, maven_key)
163-
elem.text = value
164-
else:
165-
existing.text = value
166-
167-
tree.write(str(pom_path), xml_declaration=True, encoding="UTF-8")
174+
new_lines.append(f"{child_indent}<{maven_key}>{value}</{maven_key}>")
175+
176+
properties_block = "\n".join(new_lines)
177+
178+
# Insert before </properties>
179+
if props_close:
180+
content = (
181+
content[: props_close.start()]
182+
+ properties_block
183+
+ "\n"
184+
+ parent_indent
185+
+ "</properties>"
186+
+ content[props_close.end() :]
187+
)
188+
else:
189+
# No <properties> section — create one before </project>
190+
project_close = re.search(r"([ \t]*)</project>", content)
191+
if project_close:
192+
indent = project_close.group(1)
193+
inner = " " + indent
194+
props_section = (
195+
f"{inner}<properties>\n"
196+
+ "\n".join(f" {line}" for line in new_lines)
197+
+ f"\n{inner}</properties>\n"
198+
)
199+
content = (
200+
content[: project_close.start()]
201+
+ props_section
202+
+ indent
203+
+ "</project>"
204+
+ content[project_close.end() :]
205+
)
206+
207+
pom_path.write_text(content, encoding="utf-8")
168208
return True, f"Config saved to {pom_path} <properties>"
169209

170210
except Exception as e:
@@ -173,15 +213,6 @@ def _write_maven_properties(pom_path: Path, config: dict[str, Any]) -> tuple[boo
173213

174214
def _write_gradle_properties(props_path: Path, config: dict[str, Any]) -> tuple[bool, str]:
175215
"""Add codeflash.* entries to gradle.properties."""
176-
key_map = {
177-
"module-root": "moduleRoot",
178-
"tests-root": "testsRoot",
179-
"git-remote": "gitRemote",
180-
"disable-telemetry": "disableTelemetry",
181-
"ignore-paths": "ignorePaths",
182-
"formatter-cmds": "formatterCmds",
183-
}
184-
185216
try:
186217
lines = []
187218
if props_path.exists():
@@ -195,7 +226,7 @@ def _write_gradle_properties(props_path: Path, config: dict[str, Any]) -> tuple[
195226
lines.append("")
196227
lines.append("# Codeflash configuration — https://docs.codeflash.ai")
197228
for key, value in config.items():
198-
gradle_key = f"codeflash.{key_map.get(key, key)}"
229+
gradle_key = f"codeflash.{_MAVEN_KEY_MAP.get(key, key)}"
199230
if isinstance(value, list):
200231
value = ",".join(str(v) for v in value)
201232
elif isinstance(value, bool):
@@ -306,8 +337,25 @@ def _remove_from_pyproject(project_root: Path) -> tuple[bool, str]:
306337

307338

308339
def _remove_java_build_config(project_root: Path) -> tuple[bool, str]:
309-
"""Remove codeflash.* properties from pom.xml or gradle.properties."""
310-
# Try gradle.properties first (simpler)
340+
"""Remove codeflash.* properties from pom.xml or gradle.properties.
341+
342+
Priority matches _write_java_build_config: pom.xml first, then gradle.properties.
343+
"""
344+
# Try pom.xml first (matches write priority) — text-based removal preserves formatting
345+
pom_path = project_root / "pom.xml"
346+
if pom_path.exists():
347+
try:
348+
import re
349+
350+
content = pom_path.read_text(encoding="utf-8")
351+
updated = re.sub(r"\n[ \t]*<codeflash\.[^>]*>[^<]*</codeflash\.[^>]*>", "", content)
352+
if updated != content:
353+
pom_path.write_text(updated, encoding="utf-8")
354+
return True, "Removed codeflash properties from pom.xml"
355+
except Exception as e:
356+
return False, f"Failed to remove config from pom.xml: {e}"
357+
358+
# Try gradle.properties
311359
gradle_props = project_root / "gradle.properties"
312360
if gradle_props.exists():
313361
try:
@@ -316,33 +364,13 @@ def _remove_java_build_config(project_root: Path) -> tuple[bool, str]:
316364
line
317365
for line in lines
318366
if not line.strip().startswith("codeflash.")
319-
and line.strip() != "# Codeflash configuration https://docs.codeflash.ai"
367+
and line.strip() != "# Codeflash configuration \u2014 https://docs.codeflash.ai"
320368
]
321369
gradle_props.write_text("\n".join(filtered) + "\n", encoding="utf-8")
322370
return True, "Removed codeflash properties from gradle.properties"
323371
except Exception as e:
324372
return False, f"Failed to remove config from gradle.properties: {e}"
325373

326-
# Try pom.xml
327-
pom_path = project_root / "pom.xml"
328-
if pom_path.exists():
329-
try:
330-
import xml.etree.ElementTree as ET
331-
332-
tree = ET.parse(str(pom_path))
333-
root = tree.getroot()
334-
ns = {"m": "http://maven.apache.org/POM/4.0.0"}
335-
for properties in [root.find("m:properties", ns), root.find("properties")]:
336-
if properties is None:
337-
continue
338-
to_remove = [child for child in properties if child.tag.split("}")[-1].startswith("codeflash.")]
339-
for elem in to_remove:
340-
properties.remove(elem)
341-
tree.write(str(pom_path), xml_declaration=True, encoding="UTF-8")
342-
return True, "Removed codeflash properties from pom.xml"
343-
except Exception as e:
344-
return False, f"Failed to remove config from pom.xml: {e}"
345-
346374
return True, "No Java build config found"
347375

348376

codeflash/setup/detector.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -900,7 +900,8 @@ def has_existing_config(project_root: Path) -> tuple[bool, str | None]:
900900
except Exception:
901901
pass
902902

903-
# Check Java build files — Java projects store config in pom.xml properties or gradle.properties
903+
# Check Java build files — for zero-config Java, any build file means "configured"
904+
# because Java config is auto-detected from build files without explicit codeflash.* properties
904905
for build_file in ("pom.xml", "build.gradle", "build.gradle.kts"):
905906
if (project_root / build_file).exists():
906907
return True, build_file
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tests for config_writer module — Java pom.xml formatting preservation."""
2+
3+
from pathlib import Path
4+
5+
6+
class TestWriteMavenProperties:
7+
"""Tests for _write_maven_properties — text-based pom.xml editing."""
8+
9+
def test_preserves_comments(self, tmp_path: Path) -> None:
10+
pom = tmp_path / "pom.xml"
11+
pom.write_text(
12+
'<?xml version="1.0" encoding="UTF-8"?>\n'
13+
"<project>\n"
14+
" <!-- Important comment -->\n"
15+
" <properties>\n"
16+
" <maven.compiler.source>17</maven.compiler.source>\n"
17+
" </properties>\n"
18+
"</project>\n",
19+
encoding="utf-8",
20+
)
21+
22+
from codeflash.setup.config_writer import _write_maven_properties
23+
24+
ok, _ = _write_maven_properties(pom, {"module-root": "src/main/java"})
25+
result = pom.read_text(encoding="utf-8")
26+
27+
assert ok
28+
assert "<!-- Important comment -->" in result
29+
assert "<codeflash.moduleRoot>src/main/java</codeflash.moduleRoot>" in result
30+
31+
def test_preserves_namespace(self, tmp_path: Path) -> None:
32+
pom = tmp_path / "pom.xml"
33+
pom.write_text(
34+
'<?xml version="1.0" encoding="UTF-8"?>\n'
35+
'<project xmlns="http://maven.apache.org/POM/4.0.0">\n'
36+
" <properties>\n"
37+
" <maven.compiler.source>17</maven.compiler.source>\n"
38+
" </properties>\n"
39+
"</project>\n",
40+
encoding="utf-8",
41+
)
42+
43+
from codeflash.setup.config_writer import _write_maven_properties
44+
45+
ok, _ = _write_maven_properties(pom, {"module-root": "src/main/java"})
46+
result = pom.read_text(encoding="utf-8")
47+
48+
assert ok
49+
assert 'xmlns="http://maven.apache.org/POM/4.0.0"' in result
50+
# Must NOT have ns0: prefix (ElementTree bug)
51+
assert "ns0:" not in result
52+
53+
def test_updates_existing_codeflash_properties(self, tmp_path: Path) -> None:
54+
pom = tmp_path / "pom.xml"
55+
pom.write_text(
56+
"<project>\n"
57+
" <properties>\n"
58+
" <codeflash.moduleRoot>old/path</codeflash.moduleRoot>\n"
59+
" </properties>\n"
60+
"</project>\n",
61+
encoding="utf-8",
62+
)
63+
64+
from codeflash.setup.config_writer import _write_maven_properties
65+
66+
ok, _ = _write_maven_properties(pom, {"module-root": "new/path"})
67+
result = pom.read_text(encoding="utf-8")
68+
69+
assert ok
70+
assert "old/path" not in result
71+
assert "<codeflash.moduleRoot>new/path</codeflash.moduleRoot>" in result
72+
73+
def test_creates_properties_section(self, tmp_path: Path) -> None:
74+
pom = tmp_path / "pom.xml"
75+
pom.write_text(
76+
"<project>\n" " <modelVersion>4.0.0</modelVersion>\n" "</project>\n",
77+
encoding="utf-8",
78+
)
79+
80+
from codeflash.setup.config_writer import _write_maven_properties
81+
82+
ok, _ = _write_maven_properties(pom, {"module-root": "src/main/java"})
83+
result = pom.read_text(encoding="utf-8")
84+
85+
assert ok
86+
assert "<properties>" in result
87+
assert "<codeflash.moduleRoot>src/main/java</codeflash.moduleRoot>" in result
88+
89+
def test_converts_kebab_to_camelcase(self, tmp_path: Path) -> None:
90+
pom = tmp_path / "pom.xml"
91+
pom.write_text(
92+
"<project>\n <properties>\n </properties>\n</project>\n",
93+
encoding="utf-8",
94+
)
95+
96+
from codeflash.setup.config_writer import _write_maven_properties
97+
98+
ok, _ = _write_maven_properties(pom, {"ignore-paths": ["target", "build"]})
99+
result = pom.read_text(encoding="utf-8")
100+
101+
assert ok
102+
assert "<codeflash.ignorePaths>target,build</codeflash.ignorePaths>" in result
103+
104+
105+
class TestRemoveJavaBuildConfig:
106+
"""Tests for _remove_java_build_config — preserves formatting during removal."""
107+
108+
def test_removes_codeflash_from_pom_preserving_others(self, tmp_path: Path) -> None:
109+
pom = tmp_path / "pom.xml"
110+
pom.write_text(
111+
"<project>\n"
112+
" <!-- Keep me -->\n"
113+
" <properties>\n"
114+
" <maven.compiler.source>17</maven.compiler.source>\n"
115+
" <codeflash.moduleRoot>src/main/java</codeflash.moduleRoot>\n"
116+
" </properties>\n"
117+
"</project>\n",
118+
encoding="utf-8",
119+
)
120+
121+
from codeflash.setup.config_writer import _remove_java_build_config
122+
123+
ok, _ = _remove_java_build_config(tmp_path)
124+
result = pom.read_text(encoding="utf-8")
125+
126+
assert ok
127+
assert "<!-- Keep me -->" in result
128+
assert "<maven.compiler.source>17</maven.compiler.source>" in result
129+
assert "codeflash.moduleRoot" not in result
130+
131+
def test_removes_codeflash_from_gradle_properties(self, tmp_path: Path) -> None:
132+
gradle = tmp_path / "gradle.properties"
133+
gradle.write_text(
134+
"org.gradle.jvmargs=-Xmx2g\n"
135+
"# Codeflash configuration \u2014 https://docs.codeflash.ai\n"
136+
"codeflash.moduleRoot=src/main/java\n"
137+
"codeflash.testsRoot=src/test/java\n",
138+
encoding="utf-8",
139+
)
140+
141+
from codeflash.setup.config_writer import _remove_java_build_config
142+
143+
ok, _ = _remove_java_build_config(tmp_path)
144+
result = gradle.read_text(encoding="utf-8")
145+
146+
assert ok
147+
assert "org.gradle.jvmargs=-Xmx2g" in result
148+
assert "codeflash." not in result

tests/test_setup/test_detector.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,22 @@ def test_returns_false_when_no_config(self, tmp_path):
558558
assert has_config is False
559559
assert config_type is None
560560

561+
def test_java_pom_xml_is_zero_config(self, tmp_path):
562+
"""Java projects with pom.xml are zero-config — build file presence means configured."""
563+
(tmp_path / "pom.xml").write_text("<project><modelVersion>4.0.0</modelVersion></project>")
564+
565+
has_config, config_type = has_existing_config(tmp_path)
566+
assert has_config is True
567+
assert config_type == "pom.xml"
568+
569+
def test_java_build_gradle_is_zero_config(self, tmp_path):
570+
"""Java projects with build.gradle are zero-config — build file presence means configured."""
571+
(tmp_path / "build.gradle").write_text('plugins { id "java" }')
572+
573+
has_config, config_type = has_existing_config(tmp_path)
574+
assert has_config is True
575+
assert config_type == "build.gradle"
576+
561577
def test_returns_false_for_empty_directory(self, tmp_path):
562578
"""Should return False for empty directory."""
563579
has_config, config_type = has_existing_config(tmp_path)

0 commit comments

Comments
 (0)