Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions applyr/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

APPLYR_DIR = Path(os.environ.get("APPLYR_HOME", Path.home() / ".applyr"))

# Generated CVs are deliverables the user has to find and attach elsewhere
# (a form, an email, LinkedIn) — unlike APPLYR_DIR's config/db/cv-master.md,
# they default outside the dotfile so a normal file browser shows them.
CV_HOME = Path(os.environ.get("APPLYR_CV_HOME", Path.home() / "Documents" / "applyr"))

# Topic display names — hardcoded, not configurable
TOPIC_LABELS = {
"tech_stack": "Tech Stack",
Expand Down Expand Up @@ -40,15 +45,17 @@
[cv]
# cv_master — the profile applyr reads to fill generated CVs. Fill it before
# running 'cv generate'; a stub file produces an empty CV.
# output_dir — where generated CVs are written.
# output_dir — where generated CVs are written. Defaults outside the applyr
# config directory so they show up in a file browser without
# unhiding dotfiles.
# Both accept any path, e.g. a private repo you already version. If you point
# them at a repo, make sure it is gitignored — CVs contain personal data.
# language — the language generated CVs are written in when an offer does not
# declare its own. Set it to the market you apply in most; an offer
# in another language overrides it via "language" in 'applyr add'.
# Supported: en, es.
cv_master = "__APPLYR_DIR__/cv-master.md"
output_dir = "__APPLYR_DIR__/cv"
output_dir = "__CV_HOME__/cv"
language = "en"
"""

Expand Down Expand Up @@ -105,7 +112,7 @@ def _build_defaults() -> dict:
"cv": {
"chrome_path": _detect_chrome(),
"cv_master": str(APPLYR_DIR / "cv-master.md"),
"output_dir": str(APPLYR_DIR / "cv"),
"output_dir": str(CV_HOME / "cv"),
"language": "en",
},
}
Expand Down Expand Up @@ -148,10 +155,13 @@ def create_default_config():

config_path = APPLYR_DIR / "applyr.toml"
if not config_path.exists():
# The template ships a placeholder rather than a literal ~/.applyr so
# that an APPLYR_HOME install does not write paths pointing outside it.
config_path.write_text(TOML_TEMPLATE.replace("__APPLYR_DIR__", str(APPLYR_DIR)))
# The template ships placeholders rather than literal paths so that an
# APPLYR_HOME/APPLYR_CV_HOME install does not write paths pointing
# outside them.
config_path.write_text(
TOML_TEMPLATE.replace("__APPLYR_DIR__", str(APPLYR_DIR)).replace("__CV_HOME__", str(CV_HOME))
)
print(f" Created {config_path}")

cv_dir = APPLYR_DIR / "cv"
cv_dir.mkdir(exist_ok=True)
cv_dir = CV_HOME / "cv"
cv_dir.mkdir(parents=True, exist_ok=True)
9 changes: 4 additions & 5 deletions applyr/cv.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,7 @@ def cmd_cv_review_blind(offer_id: int, as_json: bool = False) -> None:
conn.close()

# 2. Read cv-master.md fresh
cv_master_path = APPLYR_DIR / "cv-master.md"
cv_master_path = get_cv_master_path()
if not cv_master_path.exists():
die("Error: cv-master.md not found. Run 'applyr init' first.", code="cv_master_missing")

Expand Down Expand Up @@ -927,7 +927,7 @@ def cmd_cv_keywords(offer_id: int, as_json: bool = False) -> None:
# the command told users to run the generate step they had just run.
# `cv_used` is the authoritative link, so read it first and keep the glob
# as a fallback for hand-named files.
cv_dir = APPLYR_DIR / "cv"
cv_dir = get_output_dir()
cv_path = None

recorded = offer_data.get("cv_used")
Expand Down Expand Up @@ -1249,7 +1249,7 @@ def cmd_cv_cover_letter(offer_id: int, as_json: bool = False) -> None:
offer_data = dict(offer)

# Load cv-master
cv_master_path = APPLYR_DIR / "cv-master.md"
cv_master_path = get_cv_master_path()
if not cv_master_path.exists():
die("cv-master.md not found. Run 'applyr init' first.", code="no_cv_master")

Expand Down Expand Up @@ -1303,8 +1303,7 @@ def cmd_cv_cover_letter(offer_id: int, as_json: bool = False) -> None:

# Save to file
company_slug = offer_data.get("company", "unknown").lower().replace(" ", "-")
output_path = APPLYR_DIR / "cv" / f"cover-letter-{company_slug}.txt"
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path = get_output_dir() / f"cover-letter-{company_slug}.txt"
output_path.write_text(letter)

if as_json:
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def tmp_applyr(tmp_path, monkeypatch):
# Force config module to use the tmp path
import applyr.config as cfg
monkeypatch.setattr(cfg, "APPLYR_DIR", applyr_home)
# CV_HOME defaults outside APPLYR_DIR in production; tests keep it inside
# the same sandbox so nothing escapes tmp_path.
monkeypatch.setattr(cfg, "CV_HOME", applyr_home)

return applyr_home

Expand Down
28 changes: 28 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,31 @@ def test_does_not_overwrite(self, tmp_applyr, capsys):
(tmp_applyr / "applyr.toml").write_text("custom")
create_default_config()
assert (tmp_applyr / "applyr.toml").read_text() == "custom"


@pytest.mark.unit
class TestCvOutputDirLocation:
"""Generated CVs are a deliverable the user has to find and attach
elsewhere, unlike the config/db/cv-master.md — they must default outside
the applyr config directory so a normal file browser shows them."""

def test_default_output_dir_follows_cv_home_not_applyr_dir(self, tmp_applyr, monkeypatch, tmp_path):
import applyr.config as cfg

other_dir = tmp_path / "elsewhere"
monkeypatch.setattr(cfg, "CV_HOME", other_dir)

config = load_config()
assert config["cv"]["output_dir"] == str(other_dir / "cv")
assert not config["cv"]["output_dir"].startswith(str(tmp_applyr))

def test_written_toml_points_at_cv_home(self, tmp_applyr, monkeypatch, tmp_path):
import applyr.config as cfg

other_dir = tmp_path / "elsewhere"
monkeypatch.setattr(cfg, "CV_HOME", other_dir)

create_default_config()
toml_text = (tmp_applyr / "applyr.toml").read_text()
assert str(other_dir / "cv") in toml_text
assert (other_dir / "cv").is_dir()
43 changes: 43 additions & 0 deletions tests/test_cv.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,23 @@ def test_existing_offer_proceeds_past_null_check(self, offer_id, capsys):
assert "not found" not in err
assert "No CV found" in err

def test_finds_cv_when_output_dir_differs_from_applyr_dir(self, offer_id, tmp_applyr, monkeypatch, tmp_path):
"""The lookup hardcoded APPLYR_DIR / "cv" instead of reading the
configured output_dir, so it stopped finding CVs the moment CV_HOME
diverged from APPLYR_DIR — exactly the setup generated CVs now default
to (they live outside the config directory)."""
import applyr.config as cfg
from applyr.cv import cmd_cv_generate, cmd_cv_keywords

other_dir = tmp_path / "elsewhere"
monkeypatch.setattr(cfg, "CV_HOME", other_dir)

cmd_cv_generate(offer_id)
assert not (tmp_applyr / "cv").exists()
assert (other_dir / "cv").exists()

cmd_cv_keywords(offer_id) # must not raise "No CV found"


class TestKeywordMatchingIgnoresFrontmatter:
"""`cv keywords` matched the offer's keywords against the whole generated
Expand Down Expand Up @@ -218,3 +235,29 @@ def test_unterminated_frontmatter_is_left_alone(self):

broken = "---\noffer_id: 1\n\n# Jane Doe\n"
assert "# Jane Doe" in _strip_frontmatter(broken)


class TestCvCoverLetter:
"""The cover letter writer hardcoded APPLYR_DIR / "cv" too, so it wrote
outside the folder `applyr cv keywords` and the user were both looking in
the moment output_dir diverged from APPLYR_DIR."""

def test_writes_to_configured_output_dir(self, offer_id, tmp_applyr, monkeypatch, tmp_path):
import applyr.config as cfg
from applyr.db import get_conn
from applyr.cv import cmd_cv_cover_letter

# generate_cover_letter() needs a non-null tech_stack; the shared
# `offer_id` fixture does not set one.
conn = get_conn()
conn.execute("UPDATE offers SET tech_stack = ? WHERE id = ?", ("Python", offer_id))
conn.commit()
conn.close()

other_dir = tmp_path / "elsewhere"
monkeypatch.setattr(cfg, "CV_HOME", other_dir)

cmd_cv_cover_letter(offer_id)

assert list((other_dir / "cv").glob("cover-letter-*.txt"))
assert not (tmp_applyr / "cv").exists()
Loading