feat(docker): add docker compose support#110
feat(docker): add docker compose support#110polaminggkub-debug wants to merge 2 commits intortk-ai:masterfrom
Conversation
Add token-optimized output for docker compose commands: - `docker compose ps`: compact service listing with status and ports - `docker compose logs`: deduplicated log output via existing log engine - `docker compose build`: summary with build time, services, and step count - Unsupported compose subcommands pass through directly Includes 11 tests (8 compose format tests + 3 for existing compact_ports). Fixes rtk-ai#101 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pszymkowiak
left a comment
There was a problem hiding this comment.
Thanks for adding docker compose support! A few issues to address before merge.
The main recurring issue is bytes/chars confusion — same pattern as in PR #93. str::find() / str::rfind() return byte offsets, but the code uses them as char indices in chars().skip() or Vec<char>[idx..]. This works for pure ASCII but panics or gives wrong results with multi-byte UTF-8 characters.
src/container.rs
Outdated
| // Extract port from end of line | ||
| let port_str = if let Some(port_idx) = line_str.rfind("0.0.0.0:") { | ||
| let chars: Vec<char> = line_str.chars().collect(); | ||
| let port_chars: Vec<char> = chars[port_idx..].to_vec(); |
There was a problem hiding this comment.
Bug: rfind() returns a byte offset, but it's used here as an index into Vec<char>. If the line contains any multi-byte UTF-8 characters before the port section, this will index into the wrong position or panic with out-of-bounds.
Same pattern flagged in PR #93.
Fix: either work entirely in bytes (use &line_str[port_idx..]) or convert the byte offset to a char offset:
let port_str = if let Some(port_idx) = line_str.rfind("0.0.0.0:") {
let compact = compact_ports(line_str[port_idx..].trim());
format!(" [{}]", compact)
} else {
String::new()
};No need for the chars().collect() → Vec<char> dance here — byte slicing works fine since rfind returns a byte offset and string slicing expects byte offsets.
src/container.rs
Outdated
| let mut services: Vec<String> = Vec::new(); | ||
| for line in raw.lines() { | ||
| if let Some(start) = line.find('[') { | ||
| let after: String = line.chars().skip(start + 1).collect(); |
There was a problem hiding this comment.
Same bytes/chars bug: find('[') returns a byte offset, but chars().skip(start + 1) treats it as a char count.
Simpler fix — use byte slicing throughout:
if let Some(start) = line.find('[') {
if let Some(end) = line[start+1..].find(']') {
let bracket = &line[start+1..start+1+end];
let svc = bracket.split_whitespace().next().unwrap_or("");
// ...
}
}'[' and ']' are single-byte ASCII, so byte arithmetic is safe here.
src/container.rs
Outdated
| // Find SERVICE column (usually index 3, after quoted COMMAND) | ||
| // Find STATUS by looking for "Up" or "Exited" keywords | ||
| let line_str = *line; | ||
| let status = if line_str.contains("Up") { |
There was a problem hiding this comment.
contains("Up") is too greedy — it will false-positive on image names like setup-service:latest, commands like "python startup.py", etc.
Two options:
- Search for
" Up "(with surrounding spaces, matching docker's column formatting) - Better: use
docker compose ps --format(like the existingdocker_ps()does) to get structured output instead of parsing the human-readable table. This would avoid all the fragile column-parsing logic.
| .args(["compose", "ps"]) | ||
| .output() | ||
| .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) | ||
| .unwrap_or_default(); |
There was a problem hiding this comment.
Inconsistent error handling: unwrap_or_default() silently swallows Docker errors. If Docker isn't installed or compose isn't available, the user gets "🐳 0 compose services" with no indication something is wrong.
Compare with run_compose_logs (line 610) and run_compose_build (line 641) which use .context("Failed to run ...")?.
Should be consistent:
let output = Command::new("docker")
.args(["compose", "ps"])
.output()
.context("Failed to run docker compose ps")?;
let raw = String::from_utf8_lossy(&output.stdout).to_string();
src/container.rs
Outdated
| }; | ||
|
|
||
| // Shorten image name: keep only the last segment | ||
| let short_image = image.split('/').last().unwrap_or(image); |
There was a problem hiding this comment.
Clippy warning: .last() on a DoubleEndedIterator iterates the entire iterator needlessly. Use .next_back() instead:
let short_image = image.split('/').next_back().unwrap_or(image);
src/container.rs
Outdated
| api-1 | Server listening on port 3000 | ||
| api-1 | Connected to database"; | ||
| let out = format_compose_logs(raw); | ||
| assert!(!out.is_empty(), "should produce output"); |
There was a problem hiding this comment.
Nit: this test only checks !out.is_empty() — it doesn't validate any actual content. At minimum, assert it contains the expected prefix:
assert!(out.contains("Compose logs"), "should have compose logs header");
src/container.rs
Outdated
| for line in services.iter().take(20) { | ||
| // docker compose ps columns are whitespace-separated but STATUS can contain spaces | ||
| // Columns: NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS | ||
| // We split by 2+ spaces to handle multi-word fields |
There was a problem hiding this comment.
Nit: comment says "We split by 2+ spaces" but the code uses split_whitespace() which splits on any whitespace (single space included). Either update the comment or actually split by 2+ spaces with a regex/custom split.
|
Sorry for having you review so many issues. I really appreciate your time and feedback. I’ll make sure to double-check everything more carefully next time. |
…bytes/chars bugs
- Switch format_compose_ps to parse structured --format tab output
instead of fragile human-readable table (eliminates contains("Up")
false positives and byte/char confusion)
- Fix find('[') byte offset used with chars().skip() in build parsing
- Use .next_back() instead of .last() on DoubleEndedIterator
- Consistent error handling with .context()? in run_compose_ps
- Strengthen test assertion for compose logs
- Add tests for no-ports and long-image-path cases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary
docker compose ps,docker compose logs, anddocker compose buildup,down,restart, etc.) pass through directlycompact_ports)Commands Added
rtk docker compose psrtk docker compose logsrtk docker compose buildTest plan
cargo test container::tests::)cargo fmt --all --checkcleancargo clippy --all-targetsno new warningscargo run -- docker compose psproduces compact outputFixes #101
🤖 Generated with Claude Code