• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

devonfw / IDEasy / 26876985367

03 Jun 2026 09:48AM UTC coverage: 71.063% (-0.05%) from 71.113%
26876985367

push

github

web-flow
#1906: fix ide uninstall failing on macOS due to protected Downloads folder   (#1997)

Co-authored-by: Jörg Hohwiller <hohwille@users.noreply.github.com>

4522 of 7050 branches covered (64.14%)

Branch coverage included in aggregate %.

11716 of 15800 relevant lines covered (74.15%)

3.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

65.07
cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java
1
package com.devonfw.tools.ide.context;
2

3
import static com.devonfw.tools.ide.variable.IdeVariables.IDE_MIN_VERSION;
4

5
import java.io.ByteArrayInputStream;
6
import java.io.ByteArrayOutputStream;
7
import java.io.IOException;
8
import java.nio.file.Files;
9
import java.nio.file.Path;
10
import java.time.LocalDateTime;
11
import java.util.ArrayList;
12
import java.util.HashMap;
13
import java.util.Iterator;
14
import java.util.List;
15
import java.util.Locale;
16
import java.util.Map;
17
import java.util.Map.Entry;
18
import java.util.Objects;
19
import java.util.Properties;
20
import java.util.function.Predicate;
21
import java.util.logging.FileHandler;
22
import java.util.logging.LogManager;
23
import java.util.logging.SimpleFormatter;
24

25
import org.slf4j.Logger;
26
import org.slf4j.LoggerFactory;
27

28
import com.devonfw.tools.ide.cli.CliAbortException;
29
import com.devonfw.tools.ide.cli.CliArgument;
30
import com.devonfw.tools.ide.cli.CliArguments;
31
import com.devonfw.tools.ide.cli.CliException;
32
import com.devonfw.tools.ide.commandlet.Commandlet;
33
import com.devonfw.tools.ide.commandlet.CommandletManager;
34
import com.devonfw.tools.ide.commandlet.CommandletManagerImpl;
35
import com.devonfw.tools.ide.commandlet.ContextCommandlet;
36
import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet;
37
import com.devonfw.tools.ide.commandlet.UpdateCommandlet;
38
import com.devonfw.tools.ide.common.SystemPath;
39
import com.devonfw.tools.ide.completion.CompletionCandidate;
40
import com.devonfw.tools.ide.completion.CompletionCandidateCollector;
41
import com.devonfw.tools.ide.completion.CompletionCandidateCollectorDefault;
42
import com.devonfw.tools.ide.environment.AbstractEnvironmentVariables;
43
import com.devonfw.tools.ide.environment.EnvironmentVariables;
44
import com.devonfw.tools.ide.environment.EnvironmentVariablesType;
45
import com.devonfw.tools.ide.environment.IdeSystem;
46
import com.devonfw.tools.ide.environment.IdeSystemImpl;
47
import com.devonfw.tools.ide.git.GitContext;
48
import com.devonfw.tools.ide.git.GitContextImpl;
49
import com.devonfw.tools.ide.git.GitUrl;
50
import com.devonfw.tools.ide.io.FileAccess;
51
import com.devonfw.tools.ide.io.FileAccessImpl;
52
import com.devonfw.tools.ide.log.IdeLogArgFormatter;
53
import com.devonfw.tools.ide.log.IdeLogLevel;
54
import com.devonfw.tools.ide.log.IdeLogListener;
55
import com.devonfw.tools.ide.log.JulConsoleHandler;
56
import com.devonfw.tools.ide.merge.DirectoryMerger;
57
import com.devonfw.tools.ide.migration.IdeMigrator;
58
import com.devonfw.tools.ide.network.NetworkStatus;
59
import com.devonfw.tools.ide.network.NetworkStatusImpl;
60
import com.devonfw.tools.ide.os.SystemInfo;
61
import com.devonfw.tools.ide.os.SystemInfoImpl;
62
import com.devonfw.tools.ide.os.WindowsHelper;
63
import com.devonfw.tools.ide.os.WindowsHelperImpl;
64
import com.devonfw.tools.ide.os.WindowsPathSyntax;
65
import com.devonfw.tools.ide.process.ProcessContext;
66
import com.devonfw.tools.ide.process.ProcessContextImpl;
67
import com.devonfw.tools.ide.process.ProcessResult;
68
import com.devonfw.tools.ide.property.Property;
69
import com.devonfw.tools.ide.step.Step;
70
import com.devonfw.tools.ide.step.StepImpl;
71
import com.devonfw.tools.ide.tool.custom.CustomToolRepository;
72
import com.devonfw.tools.ide.tool.custom.CustomToolRepositoryImpl;
73
import com.devonfw.tools.ide.tool.mvn.MvnRepository;
74
import com.devonfw.tools.ide.tool.npm.NpmRepository;
75
import com.devonfw.tools.ide.tool.pip.PipRepository;
76
import com.devonfw.tools.ide.tool.repository.DefaultToolRepository;
77
import com.devonfw.tools.ide.tool.repository.ToolRepository;
78
import com.devonfw.tools.ide.url.model.UrlMetadata;
79
import com.devonfw.tools.ide.util.DateTimeUtil;
80
import com.devonfw.tools.ide.util.PrivacyUtil;
81
import com.devonfw.tools.ide.validation.ValidationResult;
82
import com.devonfw.tools.ide.validation.ValidationResultValid;
83
import com.devonfw.tools.ide.validation.ValidationState;
84
import com.devonfw.tools.ide.variable.IdeVariables;
85
import com.devonfw.tools.ide.version.IdeVersion;
86
import com.devonfw.tools.ide.version.VersionIdentifier;
87

88
/**
89
 * Abstract base implementation of {@link IdeContext}.
90
 */
91
public abstract class AbstractIdeContext implements IdeContext, IdeLogArgFormatter {
92

93
  static final Logger LOG = LoggerFactory.getLogger(AbstractIdeContext.class);
3✔
94

95
  /** The default shell bash (Bourne Again SHell). */
96
  public static final String BASH = "bash";
97

98
  private static final GitUrl IDE_URLS_GIT = new GitUrl("https://github.com/devonfw/ide-urls.git", null);
7✔
99

100
  private static final String LICENSE_URL = "https://github.com/devonfw/IDEasy/blob/main/documentation/LICENSE.adoc";
101

102
  private static final String DEFAULT_WINDOWS_GIT_PATH = "C:\\Program Files\\Git\\bin\\bash.exe";
103

104
  private static final String OPTION_DETAILS_START = "([";
105

106
  private final IdeStartContextImpl startContext;
107

108
  private Path ideHome;
109

110
  private final Path ideRoot;
111

112
  private Path confPath;
113

114
  protected Path settingsPath;
115

116
  private Path settingsCommitIdPath;
117

118
  protected Path pluginsPath;
119

120
  private Path workspacePath;
121

122
  private Path workspacesBasePath;
123

124
  private String workspaceName;
125

126
  private Path cwd;
127

128
  private Path downloadPath;
129

130
  private Path userHome;
131

132
  private Path userHomeIde;
133

134
  private SystemPath path;
135

136
  private WindowsPathSyntax pathSyntax;
137

138
  private final SystemInfo systemInfo;
139

140
  private EnvironmentVariables variables;
141

142
  private final FileAccess fileAccess;
143

144
  protected CommandletManager commandletManager;
145

146
  protected ToolRepository defaultToolRepository;
147

148
  private CustomToolRepository customToolRepository;
149

150
  private MvnRepository mvnRepository;
151

152
  private NpmRepository npmRepository;
153

154
  private PipRepository pipRepository;
155

156
  private DirectoryMerger workspaceMerger;
157

158
  protected UrlMetadata urlMetadata;
159

160
  protected Path defaultExecutionDirectory;
161

162
  private StepImpl currentStep;
163

164
  private NetworkStatus networkStatus;
165

166
  protected IdeSystem system;
167

168
  private WindowsHelper windowsHelper;
169

170
  private final Map<String, String> privacyMap;
171

172
  private Path bash;
173

174
  private boolean julConfigured;
175

176
  private Path logfile;
177

178
  /**
179
   * The constructor.
180
   *
181
   * @param startContext the {@link IdeStartContextImpl}.
182
   * @param workingDirectory the optional {@link Path} to current working directory.
183
   */
184
  public AbstractIdeContext(IdeStartContextImpl startContext, Path workingDirectory) {
185

186
    super();
2✔
187
    this.startContext = startContext;
3✔
188
    this.startContext.setArgFormatter(this);
4✔
189
    this.privacyMap = new HashMap<>();
5✔
190
    this.systemInfo = SystemInfoImpl.INSTANCE;
3✔
191
    if (isTest()) {
3!
192
      configureJavaUtilLogging(null);
3✔
193
    }
194
    this.commandletManager = new CommandletManagerImpl(this);
6✔
195
    this.fileAccess = new FileAccessImpl(this);
6✔
196
    String userHomeProperty = getSystem().getProperty("user.home");
5✔
197
    if (userHomeProperty != null) {
2!
198
      this.userHome = Path.of(userHomeProperty);
×
199
    }
200
    if (workingDirectory == null) {
2!
201
      workingDirectory = Path.of(System.getProperty("user.dir"));
×
202
    }
203
    workingDirectory = workingDirectory.toAbsolutePath();
3✔
204
    if (Files.isDirectory(workingDirectory)) {
5✔
205
      workingDirectory = this.fileAccess.toCanonicalPath(workingDirectory);
6✔
206
    } else {
207
      LOG.warn("Current working directory does not exist: {}", workingDirectory);
4✔
208
    }
209
    this.cwd = workingDirectory;
3✔
210
    // detect IDE_HOME and WORKSPACE
211
    String workspace = null;
2✔
212
    Path ideHomeDir = null;
2✔
213
    IdeHomeAndWorkspace ideHomeAndWorkspace = findIdeHome(workingDirectory);
4✔
214
    if (ideHomeAndWorkspace != null) {
2!
215
      ideHomeDir = ideHomeAndWorkspace.home();
3✔
216
      workspace = ideHomeAndWorkspace.workspace();
3✔
217
    }
218

219
    // detection completed, initializing variables
220
    this.ideRoot = findIdeRoot(ideHomeDir);
5✔
221

222
    setCwd(workingDirectory, workspace, ideHomeDir);
5✔
223

224
    if (this.ideRoot != null) {
3✔
225
      Path tempDownloadPath = getTempDownloadPath();
3✔
226
      if (Files.isDirectory(tempDownloadPath)) {
6✔
227
        // TODO delete all files older than 1 day here...
228
      } else {
229
        this.fileAccess.mkdirs(tempDownloadPath);
4✔
230
      }
231
    }
232
    this.defaultToolRepository = new DefaultToolRepository(this);
6✔
233
  }
1✔
234

235
  /**
236
   * Searches for the IDE home directory by traversing up the directory tree from the given working directory. This method can be overridden in test contexts to
237
   * add additional validation or boundary checks.
238
   *
239
   * @param workingDirectory the starting directory for the search.
240
   * @return an instance of {@link IdeHomeAndWorkspace} where the IDE_HOME was found or {@code null} if not found.
241
   */
242
  protected IdeHomeAndWorkspace findIdeHome(Path workingDirectory) {
243

244
    Path currentDir = workingDirectory;
2✔
245
    String name1 = "";
2✔
246
    String name2 = "";
2✔
247
    String workspace = WORKSPACE_MAIN;
2✔
248
    Path ideRootPath = getIdeRootPathFromEnv(false);
4✔
249

250
    while (currentDir != null) {
2✔
251
      LOG.trace("Looking for IDE_HOME in {}", currentDir);
4✔
252
      if (isIdeHome(currentDir)) {
4✔
253
        if (FOLDER_WORKSPACES.equals(name1) && !name2.isEmpty()) {
7✔
254
          workspace = name2;
3✔
255
        }
256
        break;
257
      }
258
      name2 = name1;
2✔
259
      int nameCount = currentDir.getNameCount();
3✔
260
      if (nameCount >= 1) {
3✔
261
        name1 = currentDir.getName(nameCount - 1).toString();
7✔
262
      }
263
      currentDir = currentDir.getParent();
3✔
264
      if ((ideRootPath != null) && (ideRootPath.equals(currentDir))) {
2!
265
        // prevent that during tests we traverse to the real IDE project of IDEasy developer
266
        currentDir = null;
×
267
      }
268
    }
1✔
269

270
    return new IdeHomeAndWorkspace(currentDir, workspace);
6✔
271
  }
272

273
  /**
274
   * @return a new {@link MvnRepository}
275
   */
276
  protected MvnRepository createMvnRepository() {
277
    return new MvnRepository(this);
5✔
278
  }
279

280
  /**
281
   * @return a new {@link NpmRepository}
282
   */
283
  protected NpmRepository createNpmRepository() {
284
    return new NpmRepository(this);
×
285
  }
286

287
  /**
288
   * @return a new {@link PipRepository}
289
   */
290
  protected PipRepository createPipRepository() {
291
    return new PipRepository(this);
×
292
  }
293

294
  private Path findIdeRoot(Path ideHomePath) {
295

296
    Path ideRootPath = null;
2✔
297
    if (ideHomePath != null) {
2✔
298
      Path ideRootPathFromEnv = getIdeRootPathFromEnv(true);
4✔
299
      ideRootPath = ideHomePath.getParent();
3✔
300
      if ((ideRootPathFromEnv != null) && !ideRootPath.toString().equals(ideRootPathFromEnv.toString())) {
2!
301
        LOG.warn(
×
302
            "Variable IDE_ROOT is set to '{}' but for your project '{}' the path '{}' would have been expected.\n"
303
                + "Please check your 'user.dir' or working directory setting and make sure that it matches your IDE_ROOT variable.",
304
            ideRootPathFromEnv,
305
            ideHomePath.getFileName(), ideRootPath);
×
306
      }
307
    } else if (!isTest()) {
4!
308
      ideRootPath = getIdeRootPathFromEnv(true);
×
309
    }
310
    return ideRootPath;
2✔
311
  }
312

313
  /**
314
   * @return the {@link #getIdeRoot() IDE_ROOT} from the system environment.
315
   */
316
  protected Path getIdeRootPathFromEnv(boolean withSanityCheck) {
317

318
    String root = getSystem().getEnv(IdeVariables.IDE_ROOT.getName());
×
319
    if (root != null) {
×
320
      Path rootPath = Path.of(root);
×
321
      if (Files.isDirectory(rootPath)) {
×
322
        Path absoluteRootPath = getFileAccess().toCanonicalPath(rootPath);
×
323
        if (withSanityCheck) {
×
324
          int nameCount = rootPath.getNameCount();
×
325
          int absoluteNameCount = absoluteRootPath.getNameCount();
×
326
          int delta = absoluteNameCount - nameCount;
×
327
          if (delta >= 0) {
×
328
            for (int nameIndex = 0; nameIndex < nameCount; nameIndex++) {
×
329
              String rootName = rootPath.getName(nameIndex).toString();
×
330
              String absoluteRootName = absoluteRootPath.getName(nameIndex + delta).toString();
×
331
              if (!rootName.equals(absoluteRootName)) {
×
332
                LOG.warn("IDE_ROOT is set to {} but was expanded to absolute path {} and does not match for segment {} and {} - fix your IDEasy installation!",
×
333
                    rootPath, absoluteRootPath, rootName, absoluteRootName);
334
                break;
×
335
              }
336
            }
337
          } else {
338
            LOG.warn("IDE_ROOT is set to {} but was expanded to a shorter absolute path {}", rootPath,
×
339
                absoluteRootPath);
340
          }
341
        }
342
        return absoluteRootPath;
×
343
      } else if (withSanityCheck) {
×
344
        LOG.warn("IDE_ROOT is set to {} that is not an existing directory - fix your IDEasy installation!", rootPath);
×
345
      }
346
    }
347
    return null;
×
348
  }
349

350
  @Override
351
  public void setCwd(Path userDir, String workspace, Path ideHome) {
352

353
    this.cwd = userDir;
3✔
354
    this.workspaceName = workspace;
3✔
355
    this.ideHome = ideHome;
3✔
356
    if (ideHome == null) {
2✔
357
      this.workspacesBasePath = null;
3✔
358
      this.workspacePath = null;
3✔
359
      this.confPath = null;
3✔
360
      this.settingsPath = null;
3✔
361
      this.pluginsPath = null;
4✔
362
    } else {
363
      this.workspacesBasePath = this.ideHome.resolve(FOLDER_WORKSPACES);
6✔
364
      this.workspacePath = this.workspacesBasePath.resolve(this.workspaceName);
7✔
365
      this.confPath = this.ideHome.resolve(FOLDER_CONF);
6✔
366
      this.settingsPath = this.ideHome.resolve(FOLDER_SETTINGS);
6✔
367
      this.settingsCommitIdPath = this.ideHome.resolve(IdeContext.SETTINGS_COMMIT_ID);
6✔
368
      this.pluginsPath = this.ideHome.resolve(FOLDER_PLUGINS);
6✔
369
    }
370
    if (isTest()) {
3!
371
      // only for testing...
372
      if (this.ideHome == null) {
3✔
373
        this.userHome = Path.of("/non-existing-user-home-for-testing");
7✔
374
      } else {
375
        this.userHome = this.ideHome.resolve("home");
6✔
376
      }
377
    }
378
    this.userHomeIde = this.userHome.resolve(FOLDER_DOT_IDE);
6✔
379
    this.downloadPath = computeDownloadPath(this.userHome);
6✔
380
    resetPrivacyMap();
2✔
381
    this.path = computeSystemPath();
4✔
382
  }
1✔
383

384
  /**
385
   * On macOS, {@code ~/Downloads} is protected by the OS (TCC) and the CLI may not be allowed to delete it, so we put the cache under
386
   * {@code ~/Library/Caches} instead. Tests still use {@code ~/Downloads/ide} so existing fixtures keep working.
387
   */
388
  private Path computeDownloadPath(Path home) {
389

390
    if (!isTest() && this.systemInfo.isMac()) {
3!
391
      return home.resolve("Library/Caches/IDEasy/downloads");
×
392
    }
393
    return home.resolve("Downloads/ide");
4✔
394
  }
395

396
  private String getMessageIdeHomeFound() {
397

398
    String wks = this.workspaceName;
3✔
399
    if (isPrivacyMode() && !WORKSPACE_MAIN.equals(wks)) {
3!
400
      wks = "*".repeat(wks.length());
×
401
    }
402
    return "IDE environment variables have been set for " + formatArgument(this.ideHome) + " in workspace " + wks;
7✔
403
  }
404

405
  private String getMessageNotInsideIdeProject() {
406

407
    return "You are not inside an IDE project: " + formatArgument(this.cwd);
6✔
408
  }
409

410
  private String getMessageIdeRootNotFound() {
411

412
    String root = getSystem().getEnv("IDE_ROOT");
5✔
413
    if (root == null) {
2!
414
      return "The environment variable IDE_ROOT is undefined. Please reinstall IDEasy or manually repair IDE_ROOT variable.";
2✔
415
    } else {
416
      return "The environment variable IDE_ROOT is pointing to an invalid path " + formatArgument(root)
×
417
          + ". Please reinstall IDEasy or manually repair IDE_ROOT variable.";
418
    }
419
  }
420

421
  /**
422
   * @return {@code true} if this is a test context for JUnits, {@code false} otherwise.
423
   */
424
  public boolean isTest() {
425

426
    return false;
×
427
  }
428

429
  protected SystemPath computeSystemPath() {
430

431
    return new SystemPath(this);
×
432
  }
433

434
  /**
435
   * Checks if the given directory is a valid IDE home by verifying it contains both 'workspaces' and 'settings' directories.
436
   *
437
   * @param dir the directory to check.
438
   * @return {@code true} if the directory is a valid IDE home, {@code false} otherwise.
439
   */
440
  protected boolean isIdeHome(Path dir) {
441

442
    if (!Files.isDirectory(dir.resolve("workspaces"))) {
7✔
443
      return false;
2✔
444
    } else if (!Files.isDirectory(dir.resolve("settings"))) {
7!
445
      return false;
×
446
    }
447
    return true;
2✔
448
  }
449

450
  private EnvironmentVariables createVariables() {
451

452
    AbstractEnvironmentVariables system = createSystemVariables();
3✔
453
    AbstractEnvironmentVariables user = system.extend(this.userHomeIde, EnvironmentVariablesType.USER);
6✔
454
    AbstractEnvironmentVariables settings = user.extend(this.settingsPath, EnvironmentVariablesType.SETTINGS);
6✔
455
    AbstractEnvironmentVariables workspace = settings.extend(this.workspacePath, EnvironmentVariablesType.WORKSPACE);
6✔
456
    AbstractEnvironmentVariables conf = workspace.extend(this.confPath, EnvironmentVariablesType.CONF);
6✔
457
    return conf.resolved();
3✔
458
  }
459

460
  protected AbstractEnvironmentVariables createSystemVariables() {
461

462
    return EnvironmentVariables.ofSystem(this);
3✔
463
  }
464

465
  @Override
466
  public SystemInfo getSystemInfo() {
467

468
    return this.systemInfo;
3✔
469
  }
470

471
  @Override
472
  public FileAccess getFileAccess() {
473

474
    return this.fileAccess;
3✔
475
  }
476

477
  @Override
478
  public CommandletManager getCommandletManager() {
479

480
    return this.commandletManager;
3✔
481
  }
482

483
  @Override
484
  public ToolRepository getDefaultToolRepository() {
485

486
    return this.defaultToolRepository;
3✔
487
  }
488

489
  @Override
490
  public MvnRepository getMvnRepository() {
491
    if (this.mvnRepository == null) {
3✔
492
      this.mvnRepository = createMvnRepository();
4✔
493
    }
494
    return this.mvnRepository;
3✔
495
  }
496

497
  @Override
498
  public NpmRepository getNpmRepository() {
499
    if (this.npmRepository == null) {
3✔
500
      this.npmRepository = createNpmRepository();
4✔
501
    }
502
    return this.npmRepository;
3✔
503
  }
504

505
  @Override
506
  public PipRepository getPipRepository() {
507
    if (this.pipRepository == null) {
3✔
508
      this.pipRepository = createPipRepository();
4✔
509
    }
510
    return this.pipRepository;
3✔
511
  }
512

513
  @Override
514
  public CustomToolRepository getCustomToolRepository() {
515

516
    if (this.customToolRepository == null) {
3!
517
      this.customToolRepository = CustomToolRepositoryImpl.of(this);
4✔
518
    }
519
    return this.customToolRepository;
3✔
520
  }
521

522
  @Override
523
  public Path getIdeHome() {
524

525
    return this.ideHome;
3✔
526
  }
527

528
  @Override
529
  public String getProjectName() {
530

531
    if (this.ideHome != null) {
3!
532
      return this.ideHome.getFileName().toString();
5✔
533
    }
534
    return "";
×
535
  }
536

537
  @Override
538
  public VersionIdentifier getProjectVersion() {
539

540
    if (this.ideHome != null) {
3!
541
      Path versionFile = this.ideHome.resolve(IdeContext.FILE_SOFTWARE_VERSION);
5✔
542
      if (Files.exists(versionFile)) {
5✔
543
        String version = this.fileAccess.readFileContent(versionFile).trim();
6✔
544
        return VersionIdentifier.of(version);
3✔
545
      }
546
    }
547
    return IdeMigrator.START_VERSION;
2✔
548
  }
549

550
  @Override
551
  public void setProjectVersion(VersionIdentifier version) {
552

553
    if (this.ideHome == null) {
3!
554
      throw new IllegalStateException("IDE_HOME not available!");
×
555
    }
556
    Objects.requireNonNull(version);
3✔
557
    Path versionFile = this.ideHome.resolve(IdeContext.FILE_SOFTWARE_VERSION);
5✔
558
    this.fileAccess.writeFileContent(version.toString(), versionFile);
6✔
559
  }
1✔
560

561
  @Override
562
  public Path getIdeRoot() {
563

564
    return this.ideRoot;
3✔
565
  }
566

567
  @Override
568
  public Path getIdePath() {
569

570
    Path myIdeRoot = getIdeRoot();
3✔
571
    if (myIdeRoot == null) {
2✔
572
      return null;
2✔
573
    }
574
    return myIdeRoot.resolve(FOLDER_UNDERSCORE_IDE);
4✔
575
  }
576

577
  @Override
578
  public Path getCwd() {
579

580
    return this.cwd;
3✔
581
  }
582

583
  @Override
584
  public Path getTempPath() {
585

586
    Path idePath = getIdePath();
3✔
587
    if (idePath == null) {
2!
588
      return null;
×
589
    }
590
    return idePath.resolve("tmp");
4✔
591
  }
592

593
  @Override
594
  public Path getTempDownloadPath() {
595

596
    Path tmp = getTempPath();
3✔
597
    if (tmp == null) {
2!
598
      return null;
×
599
    }
600
    return tmp.resolve(FOLDER_DOWNLOADS);
4✔
601
  }
602

603
  @Override
604
  public Path getUserHome() {
605

606
    return this.userHome;
3✔
607
  }
608

609
  /**
610
   * This method should only be used for tests to mock user home.
611
   *
612
   * @param userHome the new value of {@link #getUserHome()}.
613
   */
614
  protected void setUserHome(Path userHome) {
615

616
    this.userHome = userHome;
3✔
617
    this.userHomeIde = userHome.resolve(FOLDER_DOT_IDE);
5✔
618
    this.downloadPath = computeDownloadPath(userHome);
5✔
619
    this.variables = null;
3✔
620
    resetPrivacyMap();
2✔
621
  }
1✔
622

623
  @Override
624
  public Path getUserHomeIde() {
625

626
    return this.userHomeIde;
3✔
627
  }
628

629
  @Override
630
  public Path getSettingsPath() {
631

632
    return this.settingsPath;
3✔
633
  }
634

635
  @Override
636
  public Path getSettingsGitRepository() {
637

638
    Path settingsPath = getSettingsPath();
3✔
639
    // check whether the settings path has a .git folder only if its not a symbolic link or junction
640
    if ((settingsPath != null) && !Files.exists(settingsPath.resolve(".git")) && !isSettingsCodeRepository()) {
12!
641
      LOG.error("Settings repository exists but is not a git repository.");
3✔
642
      return null;
2✔
643
    }
644
    return settingsPath;
2✔
645
  }
646

647
  @Override
648
  public boolean isSettingsCodeRepository() {
649

650
    Path settingsPath = getSettingsPath();
3✔
651
    if (settingsPath != null) {
2!
652
      boolean settingsIsLink = Files.isSymbolicLink(settingsPath) || getFileAccess().isJunction(settingsPath);
10!
653
      if (settingsIsLink) {
2!
654
        Path realPath = getFileAccess().toRealPath(this.settingsPath);
×
655
        if (realPath != null) {
×
656
          return getGitContext().isGitRepo(realPath.getParent());
×
657
        }
658
        return true;
×
659
      }
660
    }
661
    return false;
2✔
662
  }
663

664
  @Override
665
  public Path getSettingsCommitIdPath() {
666

667
    return this.settingsCommitIdPath;
3✔
668
  }
669

670
  @Override
671
  public Path getConfPath() {
672

673
    return this.confPath;
3✔
674
  }
675

676
  @Override
677
  public Path getSoftwarePath() {
678

679
    if (this.ideHome == null) {
3✔
680
      return null;
2✔
681
    }
682
    return this.ideHome.resolve(FOLDER_SOFTWARE);
5✔
683
  }
684

685
  @Override
686
  public Path getSoftwareExtraPath() {
687

688
    Path softwarePath = getSoftwarePath();
3✔
689
    if (softwarePath == null) {
2!
690
      return null;
×
691
    }
692
    return softwarePath.resolve(FOLDER_EXTRA);
4✔
693
  }
694

695
  @Override
696
  public Path getSoftwareRepositoryPath() {
697

698
    Path idePath = getIdePath();
3✔
699
    if (idePath == null) {
2!
700
      return null;
×
701
    }
702
    return idePath.resolve(FOLDER_SOFTWARE);
4✔
703
  }
704

705
  @Override
706
  public Path getPluginsPath() {
707

708
    return this.pluginsPath;
3✔
709
  }
710

711
  @Override
712
  public String getWorkspaceName() {
713

714
    return this.workspaceName;
3✔
715
  }
716

717
  @Override
718
  public Path getWorkspacesBasePath() {
719

720
    return this.workspacesBasePath;
3✔
721
  }
722

723
  @Override
724
  public Path getWorkspacePath() {
725

726
    return this.workspacePath;
3✔
727
  }
728

729
  @Override
730
  public Path getWorkspacePath(String workspace) {
731

732
    if (this.workspacesBasePath == null) {
3!
733
      throw new IllegalStateException("Failed to access workspace " + workspace + " without IDE_HOME in " + this.cwd);
×
734
    }
735
    return this.workspacesBasePath.resolve(workspace);
5✔
736
  }
737

738
  @Override
739
  public Path getDownloadPath() {
740

741
    return this.downloadPath;
3✔
742
  }
743

744
  @Override
745
  public Path getUrlsPath() {
746

747
    Path idePath = getIdePath();
3✔
748
    if (idePath == null) {
2!
749
      return null;
×
750
    }
751
    return idePath.resolve(FOLDER_URLS);
4✔
752
  }
753

754
  @Override
755
  public Path getToolRepositoryPath() {
756

757
    Path idePath = getIdePath();
3✔
758
    if (idePath == null) {
2!
759
      return null;
×
760
    }
761
    return idePath.resolve(FOLDER_SOFTWARE);
4✔
762
  }
763

764
  @Override
765
  public SystemPath getPath() {
766

767
    return this.path;
3✔
768
  }
769

770
  @Override
771
  public EnvironmentVariables getVariables() {
772

773
    if (this.variables == null) {
3✔
774
      this.variables = createVariables();
4✔
775
    }
776
    return this.variables;
3✔
777
  }
778

779
  @Override
780
  public UrlMetadata getUrls() {
781

782
    if (this.urlMetadata == null) {
3✔
783
      if (!isTest()) {
3!
784
        getGitContext().pullOrCloneAndResetIfNeeded(IDE_URLS_GIT, getUrlsPath(), null);
×
785
      }
786
      this.urlMetadata = new UrlMetadata(this);
6✔
787
    }
788
    return this.urlMetadata;
3✔
789
  }
790

791
  @Override
792
  public boolean isQuietMode() {
793

794
    return this.startContext.isQuietMode();
4✔
795
  }
796

797
  @Override
798
  public boolean isBatchMode() {
799

800
    return this.startContext.isBatchMode();
4✔
801
  }
802

803
  @Override
804
  public boolean isForceMode() {
805

806
    return this.startContext.isForceMode();
4✔
807
  }
808

809
  @Override
810
  public boolean isForcePull() {
811

812
    return this.startContext.isForcePull();
4✔
813
  }
814

815
  @Override
816
  public boolean isForcePlugins() {
817

818
    return this.startContext.isForcePlugins();
4✔
819
  }
820

821
  @Override
822
  public boolean isForceRepositories() {
823

824
    return this.startContext.isForceRepositories();
4✔
825
  }
826

827
  @Override
828
  public boolean isOfflineMode() {
829

830
    return this.startContext.isOfflineMode();
4✔
831
  }
832

833
  @Override
834
  public boolean isPrivacyMode() {
835
    return this.startContext.isPrivacyMode();
4✔
836
  }
837

838
  @Override
839
  public boolean isSkipUpdatesMode() {
840

841
    return this.startContext.isSkipUpdatesMode();
4✔
842
  }
843

844
  @Override
845
  public boolean isNoColorsMode() {
846

847
    return this.startContext.isNoColorsMode();
×
848
  }
849

850
  @Override
851
  public NetworkStatus getNetworkStatus() {
852

853
    if (this.networkStatus == null) {
×
854
      this.networkStatus = new NetworkStatusImpl(this);
×
855
    }
856
    return this.networkStatus;
×
857
  }
858

859
  @Override
860
  public Locale getLocale() {
861

862
    Locale locale = this.startContext.getLocale();
4✔
863
    if (locale == null) {
2✔
864
      locale = Locale.getDefault();
2✔
865
    }
866
    return locale;
2✔
867
  }
868

869
  @Override
870
  public DirectoryMerger getWorkspaceMerger() {
871

872
    if (this.workspaceMerger == null) {
3✔
873
      this.workspaceMerger = new DirectoryMerger(this);
6✔
874
    }
875
    return this.workspaceMerger;
3✔
876
  }
877

878
  /**
879
   * @return the default execution directory in which a command process is executed.
880
   */
881
  @Override
882
  public Path getDefaultExecutionDirectory() {
883

884
    return this.defaultExecutionDirectory;
×
885
  }
886

887
  /**
888
   * @param defaultExecutionDirectory new value of {@link #getDefaultExecutionDirectory()}.
889
   */
890
  public void setDefaultExecutionDirectory(Path defaultExecutionDirectory) {
891

892
    if (defaultExecutionDirectory != null) {
×
893
      this.defaultExecutionDirectory = defaultExecutionDirectory;
×
894
    }
895
  }
×
896

897
  @Override
898
  public GitContext getGitContext() {
899

900
    return new GitContextImpl(this);
×
901
  }
902

903
  @Override
904
  public ProcessContext newProcess() {
905

906
    ProcessContext processContext = createProcessContext();
3✔
907
    if (this.defaultExecutionDirectory != null) {
3!
908
      processContext.directory(this.defaultExecutionDirectory);
×
909
    }
910
    return processContext;
2✔
911
  }
912

913
  @Override
914
  public IdeSystem getSystem() {
915

916
    if (this.system == null) {
×
917
      this.system = new IdeSystemImpl();
×
918
    }
919
    return this.system;
×
920
  }
921

922
  /**
923
   * @return a new instance of {@link ProcessContext}.
924
   * @see #newProcess()
925
   */
926
  protected ProcessContext createProcessContext() {
927

928
    return new ProcessContextImpl(this);
×
929
  }
930

931
  @Override
932
  public IdeLogLevel getLogLevelConsole() {
933

934
    return this.startContext.getLogLevelConsole();
×
935
  }
936

937
  @Override
938
  public IdeLogLevel getLogLevelLogger() {
939

940
    return this.startContext.getLogLevelLogger();
×
941
  }
942

943
  @Override
944
  public IdeLogListener getLogListener() {
945

946
    return this.startContext.getLogListener();
×
947
  }
948

949
  @Override
950
  public void logIdeHomeAndRootStatus() {
951
    if (this.ideRoot != null) {
3!
952
      IdeLogLevel.SUCCESS.log(LOG, "IDE_ROOT is set to {}", this.ideRoot);
×
953
    }
954
    if (this.ideHome == null) {
3✔
955
      LOG.warn(getMessageNotInsideIdeProject());
5✔
956
    } else {
957
      IdeLogLevel.SUCCESS.log(LOG, "IDE_HOME is set to {}", this.ideHome);
11✔
958
    }
959
  }
1✔
960

961
  @Override
962
  public String formatArgument(Object argument) {
963

964
    if (argument == null) {
2✔
965
      return null;
2✔
966
    }
967
    String result = argument.toString();
3✔
968
    if (isPrivacyMode()) {
3✔
969
      if ((this.ideRoot != null) && this.privacyMap.isEmpty()) {
3!
970
        initializePrivacyMap(this.userHome, "~");
×
971
        String projectName = getProjectName();
×
972
        if (!projectName.isEmpty()) {
×
973
          this.privacyMap.put(projectName, "project");
×
974
        }
975
      }
976
      for (Entry<String, String> entry : this.privacyMap.entrySet()) {
8!
977
        result = result.replace(entry.getKey(), entry.getValue());
×
978
      }
×
979
      result = PrivacyUtil.removeSensitivePathInformation(result);
3✔
980
    }
981
    return result;
2✔
982
  }
983

984
  /**
985
   * @param path the sensitive {@link Path} to
986
   * @param replacement the replacement to mask the {@link Path} in log output.
987
   */
988
  protected void initializePrivacyMap(Path path, String replacement) {
989

990
    if (path == null) {
×
991
      return;
×
992
    }
993
    if (this.systemInfo.isWindows()) {
×
994
      this.privacyMap.put(WindowsPathSyntax.WINDOWS.format(path), replacement);
×
995
      this.privacyMap.put(WindowsPathSyntax.MSYS.format(path), replacement);
×
996
    } else {
997
      this.privacyMap.put(path.toString(), replacement);
×
998
    }
999
  }
×
1000

1001
  /**
1002
   * Resets the privacy map in case fundamental values have changed.
1003
   */
1004
  private void resetPrivacyMap() {
1005

1006
    this.privacyMap.clear();
3✔
1007
  }
1✔
1008

1009

1010
  @Override
1011
  public String askForInput(String message, String defaultValue) {
1012

1013
    while (true) {
1014
      if (!message.isBlank()) {
3!
1015
        IdeLogLevel.INTERACTION.log(LOG, message);
4✔
1016
      }
1017
      if (isBatchMode()) {
3!
1018
        if (isForceMode()) {
×
1019
          return defaultValue;
×
1020
        } else {
1021
          throw new CliAbortException();
×
1022
        }
1023
      }
1024
      String input = readLine().trim();
4✔
1025
      if (!input.isEmpty()) {
3!
1026
        return input;
2✔
1027
      } else {
1028
        if (defaultValue != null) {
×
1029
          return defaultValue;
×
1030
        }
1031
      }
1032
    }
×
1033
  }
1034

1035
  @Override
1036
  public <O> O question(O[] options, String question, Object... args) {
1037

1038
    assert (options.length > 0);
4!
1039
    IdeLogLevel.INTERACTION.log(LOG, question, args);
5✔
1040
    return displayOptionsAndGetAnswer(options);
4✔
1041
  }
1042

1043
  private <O> O displayOptionsAndGetAnswer(O[] options) {
1044
    Map<String, O> mapping = new HashMap<>(options.length);
6✔
1045
    int i = 0;
2✔
1046
    for (O option : options) {
16✔
1047
      i++;
1✔
1048
      String title = "" + option;
4✔
1049
      String key = computeOptionKey(title);
3✔
1050
      addMapping(mapping, key, option);
4✔
1051
      String numericKey = Integer.toString(i);
3✔
1052
      if (numericKey.equals(key)) {
4!
1053
        LOG.trace("Options should not be numeric: {}", key);
×
1054
      } else {
1055
        addMapping(mapping, numericKey, option);
4✔
1056
      }
1057
      IdeLogLevel.INTERACTION.log(LOG, "Option {}: {}", numericKey, title);
14✔
1058
    }
1059
    if (options.length == 1) {
4✔
1060
      mapping.put("", options[0]);
7✔
1061
    }
1062
    O option = null;
2✔
1063
    if (isBatchMode()) {
3!
1064
      if (isForceMode()) {
×
1065
        option = options[0];
×
1066
        IdeLogLevel.INTERACTION.log(LOG, "" + option);
×
1067
      }
1068
    } else {
1069
      while (option == null) {
2✔
1070
        String answer = readLine();
3✔
1071
        option = mapping.get(answer);
4✔
1072
        if (option == null) {
2!
1073
          LOG.warn("Invalid answer: '{}' - please try again.", answer);
×
1074
        }
1075
      }
1✔
1076
    }
1077
    return option;
2✔
1078
  }
1079

1080
  private static String computeOptionKey(String option) {
1081
    String key = option;
2✔
1082
    int index = -1;
2✔
1083
    for (char c : OPTION_DETAILS_START.toCharArray()) {
17✔
1084
      int currentIndex = key.indexOf(c);
4✔
1085
      if (currentIndex != -1) {
3✔
1086
        if ((index == -1) || (currentIndex < index)) {
3!
1087
          index = currentIndex;
2✔
1088
        }
1089
      }
1090
    }
1091
    if (index > 0) {
2✔
1092
      key = key.substring(0, index).trim();
6✔
1093
    }
1094
    return key;
2✔
1095
  }
1096

1097
  /**
1098
   * @return the input from the end-user (e.g. read from the console).
1099
   */
1100
  protected abstract String readLine();
1101

1102
  private static <O> void addMapping(Map<String, O> mapping, String key, O option) {
1103

1104
    O duplicate = mapping.put(key, option);
5✔
1105
    if (duplicate != null) {
2!
1106
      throw new IllegalArgumentException("Duplicated option " + key);
×
1107
    }
1108
  }
1✔
1109

1110
  @Override
1111
  public Step getCurrentStep() {
1112

1113
    return this.currentStep;
×
1114
  }
1115

1116
  @Override
1117
  public StepImpl newStep(boolean silent, String name, Object... parameters) {
1118

1119
    this.currentStep = new StepImpl(this, this.currentStep, name, silent, parameters);
11✔
1120
    return this.currentStep;
3✔
1121
  }
1122

1123
  /**
1124
   * Internal method to end the running {@link Step}.
1125
   *
1126
   * @param step the current {@link Step} to end.
1127
   */
1128
  public void endStep(StepImpl step) {
1129

1130
    if (step == this.currentStep) {
4!
1131
      this.currentStep = this.currentStep.getParent();
6✔
1132
    } else {
1133
      String currentStepName = "null";
×
1134
      if (this.currentStep != null) {
×
1135
        currentStepName = this.currentStep.getName();
×
1136
      }
1137
      LOG.warn("endStep called with wrong step '{}' but expected '{}'", step.getName(), currentStepName);
×
1138
    }
1139
  }
1✔
1140

1141
  /**
1142
   * Finds the matching {@link Commandlet} to run, applies {@link CliArguments} to its {@link Commandlet#getProperties() properties} and will execute it.
1143
   *
1144
   * @param arguments the {@link CliArgument}.
1145
   * @return the return code of the execution.
1146
   */
1147
  public int run(CliArguments arguments) {
1148

1149
    CliArgument current = arguments.current();
3✔
1150
    assert (this.currentStep == null);
4!
1151
    boolean supressStepSuccess = false;
2✔
1152
    StepImpl step = newStep(true, "ide", (Object[]) current.asArray());
8✔
1153
    Iterator<Commandlet> commandletIterator = this.commandletManager.findCommandlet(arguments, null);
6✔
1154
    Commandlet cmd = null;
2✔
1155
    ValidationResult result = null;
2✔
1156
    try {
1157
      while (commandletIterator.hasNext()) {
3✔
1158
        cmd = commandletIterator.next();
4✔
1159
        result = applyAndRun(arguments.copy(), cmd);
6✔
1160
        if (result.isValid()) {
3!
1161
          supressStepSuccess = cmd.isSuppressStepSuccess();
3✔
1162
          step.success();
2✔
1163
          return ProcessResult.SUCCESS;
4✔
1164
        }
1165
      }
1166
      activateLogging(cmd);
3✔
1167
      verifyIdeMinVersion(false);
3✔
1168
      if (result != null) {
2!
1169
        LOG.error(result.getErrorMessage());
×
1170
      }
1171
      step.error("Invalid arguments: {}", current.getArgs());
10✔
1172
      IdeLogLevel.INTERACTION.log(LOG, "For additional details run ide help {}", cmd == null ? "" : cmd.getName());
13!
1173
      return 1;
4✔
1174
    } catch (Throwable t) {
1✔
1175
      activateLogging(cmd);
3✔
1176
      step.error(t, true);
4✔
1177
      if (this.logfile != null) {
3!
1178
        System.err.println("Logfile can be found at " + this.logfile); // do not use logger
×
1179
      }
1180
      throw t;
2✔
1181
    } finally {
1182
      step.close();
2✔
1183
      assert (this.currentStep == null);
4!
1184
      step.logSummary(supressStepSuccess);
3✔
1185
    }
1186
  }
1187

1188
  /**
1189
   * Ensure the logging system is initialized.
1190
   */
1191
  private void activateLogging(Commandlet cmd) {
1192

1193
    configureJavaUtilLogging(cmd);
3✔
1194
    this.startContext.activateLogging();
3✔
1195
  }
1✔
1196

1197
  /**
1198
   * Configures the logging system (JUL).
1199
   *
1200
   * @param cmd the {@link Commandlet} to be called. May be {@code null}.
1201
   */
1202
  public void configureJavaUtilLogging(Commandlet cmd) {
1203

1204
    if (this.julConfigured) {
3✔
1205
      return;
1✔
1206
    }
1207
    boolean writeLogfile = isWriteLogfile(cmd);
4✔
1208
    this.startContext.setWriteLogfile(writeLogfile);
4✔
1209
    Properties properties = createJavaUtilLoggingProperties(writeLogfile, cmd);
5✔
1210
    try {
1211
      ByteArrayOutputStream out = new ByteArrayOutputStream(512);
5✔
1212
      properties.store(out, null);
4✔
1213
      out.flush();
2✔
1214
      ByteArrayInputStream in = new ByteArrayInputStream(out.toByteArray());
6✔
1215
      LogManager.getLogManager().readConfiguration(in);
3✔
1216
      this.julConfigured = true;
3✔
1217
      this.startContext.activateLogging();
3✔
1218
    } catch (IOException e) {
×
1219
      LOG.error("Failed to configure logging: {}", e.toString(), e);
×
1220
    }
1✔
1221
  }
1✔
1222

1223
  protected boolean isWriteLogfile(Commandlet cmd) {
1224
    if ((cmd == null) || !cmd.isWriteLogFile()) {
×
1225
      return false;
×
1226
    }
1227
    Boolean writeLogfile = IdeVariables.IDE_WRITE_LOGFILE.get(this);
×
1228
    return Boolean.TRUE.equals(writeLogfile);
×
1229
  }
1230

1231
  private Properties createJavaUtilLoggingProperties(boolean writeLogfile, Commandlet cmd) {
1232

1233
    Path idePath = getIdePath();
3✔
1234
    if (writeLogfile && (idePath == null)) {
2!
1235
      writeLogfile = false;
×
1236
      LOG.error("Cannot enable log-file since IDE_ROOT is undefined.");
×
1237
    }
1238
    Properties properties = new Properties();
4✔
1239
    // prevent 3rd party (e.g. java.lang.ProcessBuilder) logging into our console via JUL
1240
    // see JulLogLevel for the trick we did to workaround JUL flaws
1241
    properties.setProperty(".level", "SEVERE");
5✔
1242
    if (writeLogfile) {
2!
1243
      this.startContext.setLogLevelLogger(IdeLogLevel.TRACE);
×
1244
      String fileHandlerName = FileHandler.class.getName();
×
1245
      properties.setProperty("handlers", JulConsoleHandler.class.getName() + "," + fileHandlerName);
×
1246
      properties.setProperty(fileHandlerName + ".formatter", SimpleFormatter.class.getName());
×
1247
      properties.setProperty(fileHandlerName + ".encoding", "UTF-8");
×
1248
      this.logfile = createLogfilePath(idePath, cmd);
×
1249
      getFileAccess().mkdirs(this.logfile.getParent());
×
1250
      properties.setProperty(fileHandlerName + ".pattern", this.logfile.toString());
×
1251
    } else {
×
1252
      properties.setProperty("handlers", JulConsoleHandler.class.getName());
6✔
1253
    }
1254
    properties.setProperty(SimpleFormatter.class.getName() + ".format", "%1$tY-%1$tm-%1$td %1$tH:%1$tM:%1$tS.%1$tL [%4$s] [%3$s] %5$s%6$s%n");
7✔
1255
    return properties;
2✔
1256
  }
1257

1258
  private Path createLogfilePath(Path idePath, Commandlet cmd) {
1259
    LocalDateTime now = LocalDateTime.now();
×
1260
    Path logsPath = idePath.resolve(FOLDER_LOGS).resolve(DateTimeUtil.formatDate(now, true));
×
1261
    StringBuilder sb = new StringBuilder(32);
×
1262
    if (this.ideHome == null || ((cmd != null) && !cmd.isIdeHomeRequired())) {
×
1263
      sb.append("_ide-");
×
1264
    } else {
1265
      sb.append(this.ideHome.getFileName().toString());
×
1266
      sb.append('-');
×
1267
    }
1268
    sb.append("ide-");
×
1269
    if (cmd != null) {
×
1270
      sb.append(cmd.getName());
×
1271
      sb.append('-');
×
1272
    }
1273
    sb.append(DateTimeUtil.formatTime(now));
×
1274
    sb.append(".log");
×
1275
    return logsPath.resolve(sb.toString());
×
1276
  }
1277

1278
  @Override
1279
  public void runWithoutLogging(Runnable lambda, IdeLogLevel threshold) {
1280

1281
    this.startContext.deactivateLogging(threshold);
4✔
1282
    lambda.run();
2✔
1283
    this.startContext.activateLogging();
3✔
1284
  }
1✔
1285

1286
  /**
1287
   * @param cmd the potential {@link Commandlet} to {@link #apply(CliArguments, Commandlet) apply} and {@link Commandlet#run() run}.
1288
   * @return {@code true} if the given {@link Commandlet} matched and did {@link Commandlet#run() run} successfully, {@code false} otherwise (the
1289
   *     {@link Commandlet} did not match and we have to try a different candidate).
1290
   */
1291
  private ValidationResult applyAndRun(CliArguments arguments, Commandlet cmd) {
1292

1293
    IdeLogLevel previousLogLevel = null;
2✔
1294
    cmd.reset();
2✔
1295
    ValidationResult result = apply(arguments, cmd);
5✔
1296
    if (result.isValid()) {
3!
1297
      result = cmd.validate();
3✔
1298
    }
1299
    if (result.isValid()) {
3!
1300
      LOG.debug("Running commandlet {}", cmd);
4✔
1301
      if (cmd.isIdeHomeRequired() && (this.ideHome == null)) {
6!
1302
        throw new CliException(getMessageNotInsideIdeProject(), ProcessResult.NO_IDE_HOME);
×
1303
      } else if (cmd.isIdeRootRequired() && (this.ideRoot == null)) {
6!
1304
        throw new CliException(getMessageIdeRootNotFound(), ProcessResult.NO_IDE_ROOT);
7✔
1305
      }
1306
      try {
1307
        if (cmd.isProcessableOutput()) {
3!
1308
          if (!LOG.isDebugEnabled()) {
×
1309
            // unless --debug or --trace was supplied, processable output commandlets will disable all log-levels except INFO to prevent other logs interfere
1310
            previousLogLevel = this.startContext.setLogLevelConsole(IdeLogLevel.PROCESSABLE);
×
1311
          }
1312
        } else {
1313
          if (cmd.isIdeHomeRequired()) {
3!
1314
            LOG.debug(getMessageIdeHomeFound());
4✔
1315
          }
1316
          Path settingsRepository = getSettingsGitRepository();
3✔
1317
          if (settingsRepository != null) {
2!
1318
            if (getGitContext().isRepositoryUpdateAvailable(settingsRepository, getSettingsCommitIdPath()) || (
8!
1319
                getGitContext().fetchIfNeeded(settingsRepository) && getGitContext().isRepositoryUpdateAvailable(
4!
1320
                    settingsRepository, getSettingsCommitIdPath()))) {
×
1321

1322
              // Inform the user that an update is available. The update message is suppressed if we are already running the update
1323
              String msg = determineSettingsUpdateMessage(cmd);
×
1324
              if (msg != null) {
×
1325
                IdeLogLevel.INTERACTION.log(LOG, msg);
×
1326
              }
1327
            }
1328
          }
1329
        }
1330
        boolean success = ensureLicenseAgreement(cmd);
4✔
1331
        if (!success) {
2!
1332
          return ValidationResultValid.get();
×
1333
        }
1334
        cmd.run();
2✔
1335
      } finally {
1336
        if (previousLogLevel != null) {
2!
1337
          this.startContext.setLogLevelConsole(previousLogLevel);
×
1338
        }
1339
      }
1✔
1340
    } else {
1341
      LOG.trace("Commandlet did not match");
×
1342
    }
1343
    return result;
2✔
1344
  }
1345

1346

1347
  /**
1348
   * When an update is available for the settings repository, we log a message to the console, reminding the user to run {@code ide update}. This method
1349
   * determines the correct message to log, depending on whether the settings repository is a symlink/junction, or not. Should the user already be running the
1350
   * appropriate {@code ide update} command, the message is suppressed to avoid confusion.
1351
   *
1352
   * @param cmd the {@link Commandlet}.
1353
   * @return {@code msg} to log to the console. {@code null} if the message is suppressed.
1354
   */
1355
  private String determineSettingsUpdateMessage(Commandlet cmd) {
1356
    boolean update = cmd instanceof UpdateCommandlet;
×
1357
    if (isSettingsCodeRepository()) {
×
1358
      if (update && (isForceMode() || isForcePull())) {
×
1359
        return null;
×
1360
      }
1361
      return "Updates are available for the settings repository. Please pull the latest changes by yourself or by calling \"ide -f update\" to apply them.";
×
1362
    } else {
1363
      if (update) {
×
1364
        return null;
×
1365
      }
1366
      return "Updates are available for the settings repository. If you want to apply the latest changes, call \"ide update\"";
×
1367
    }
1368
  }
1369

1370
  private boolean ensureLicenseAgreement(Commandlet cmd) {
1371

1372
    if (isTest()) {
3!
1373
      return true; // ignore for tests
2✔
1374
    }
1375
    getFileAccess().mkdirs(this.userHomeIde);
×
1376
    Path licenseAgreement = this.userHomeIde.resolve(FILE_LICENSE_AGREEMENT);
×
1377
    if (Files.isRegularFile(licenseAgreement)) {
×
1378
      return true; // success, license already accepted
×
1379
    }
1380
    if (cmd instanceof EnvironmentCommandlet) {
×
1381
      // if the license was not accepted, "$(ideasy env --bash)" that is written into a variable prevents the user from seeing the question he is asked
1382
      // in such situation the user could not open a bash terminal anymore and gets blocked what would really annoy the user so we exit here without doing or
1383
      // printing anything anymore in such case.
1384
      return false;
×
1385
    }
1386
    activateLogging(cmd);
×
1387
    IdeLogLevel oldLogLevel = this.startContext.getLogLevelConsole();
×
1388
    IdeLogLevel newLogLevel = oldLogLevel;
×
1389
    if (oldLogLevel.ordinal() > IdeLogLevel.INFO.ordinal()) {
×
1390
      newLogLevel = IdeLogLevel.INFO;
×
1391
      this.startContext.setLogLevelConsole(newLogLevel);
×
1392
    }
1393
    StringBuilder sb = new StringBuilder(1180);
×
1394
    sb.append(LOGO).append("""
×
1395
        Welcome to IDEasy!
1396
        This product (with its included 3rd party components) is open-source software and can be used free (also commercially).
1397
        It supports automatic download and installation of arbitrary 3rd party tools.
1398
        By default only open-source 3rd party tools are used (downloaded, installed, executed).
1399
        But if explicitly configured, also commercial software that requires an additional license may be used.
1400
        This happens e.g. if you configure "ultimate" edition of IntelliJ or "docker" edition of Docker (Docker Desktop).
1401
        You are solely responsible for all risks implied by using this software.
1402
        Before using IDEasy you need to read and accept the license agreement with all involved licenses.
1403
        You will be able to find it online under the following URL:
1404
        """).append(LICENSE_URL);
×
1405
    if (this.ideRoot != null) {
×
1406
      sb.append("\n\nAlso it is included in the documentation that you can find here:\n").
×
1407
          append(getIdePath().resolve("IDEasy.pdf").toString()).append("\n");
×
1408
    }
1409
    LOG.info(sb.toString());
×
1410
    askToContinue("Do you accept these terms of use and all license agreements?");
×
1411

1412
    sb.setLength(0);
×
1413
    LocalDateTime now = LocalDateTime.now();
×
1414
    sb.append("On ").append(DateTimeUtil.formatDate(now, false)).append(" at ").append(DateTimeUtil.formatTime(now))
×
1415
        .append(" you accepted the IDEasy license.\n").append(LICENSE_URL);
×
1416
    try {
1417
      Files.writeString(licenseAgreement, sb);
×
1418
    } catch (Exception e) {
×
1419
      throw new RuntimeException("Failed to save license agreement!", e);
×
1420
    }
×
1421
    if (oldLogLevel != newLogLevel) {
×
1422
      this.startContext.setLogLevelConsole(oldLogLevel);
×
1423
    }
1424
    return true;
×
1425
  }
1426

1427
  @Override
1428
  public void verifyIdeMinVersion(boolean throwException) {
1429
    VersionIdentifier minVersion = IDE_MIN_VERSION.get(this);
5✔
1430
    if (minVersion == null) {
2✔
1431
      return;
1✔
1432
    }
1433
    VersionIdentifier versionIdentifier = IdeVersion.getVersionIdentifier();
2✔
1434
    if (versionIdentifier.compareVersion(minVersion).isLess() && !IdeVersion.isUndefined()) {
7!
1435
      String message = String.format("Your version of IDEasy is currently %s\n"
13✔
1436
          + "However, this is too old as your project requires at latest version %s\n"
1437
          + "Please run the following command to update to the latest version of IDEasy and fix the problem:\n"
1438
          + "ide upgrade", versionIdentifier, minVersion);
1439
      if (throwException) {
2✔
1440
        throw new CliException(message);
5✔
1441
      } else {
1442
        LOG.warn(message);
3✔
1443
      }
1444
    }
1445
  }
1✔
1446

1447
  /**
1448
   * @param arguments the {@link CliArguments#ofCompletion(String...) completion arguments}.
1449
   * @param includeContextOptions to include the options of {@link ContextCommandlet}.
1450
   * @return the {@link List} of {@link CompletionCandidate}s to suggest.
1451
   */
1452
  public List<CompletionCandidate> complete(CliArguments arguments, boolean includeContextOptions) {
1453

1454
    CompletionCandidateCollector collector = new CompletionCandidateCollectorDefault(this);
5✔
1455
    if (arguments.current().isStart()) {
4✔
1456
      arguments.next();
3✔
1457
    }
1458
    if (includeContextOptions) {
2✔
1459
      ContextCommandlet cc = new ContextCommandlet();
4✔
1460
      for (Property<?> property : cc.getProperties()) {
11✔
1461
        assert (property.isOption());
4!
1462
        property.apply(arguments, this, cc, collector);
7✔
1463
      }
1✔
1464
    }
1465
    Iterator<Commandlet> commandletIterator = this.commandletManager.findCommandlet(arguments, collector);
6✔
1466
    CliArgument current = arguments.current();
3✔
1467
    if (current.isCompletion() && current.isCombinedShortOption()) {
6✔
1468
      collector.add(current.get(), null, null, null);
7✔
1469
    }
1470
    arguments.next();
3✔
1471
    while (commandletIterator.hasNext()) {
3✔
1472
      Commandlet cmd = commandletIterator.next();
4✔
1473
      if (!arguments.current().isEnd()) {
4✔
1474
        completeCommandlet(arguments.copy(), cmd, collector);
6✔
1475
      }
1476
    }
1✔
1477
    return collector.getSortedCandidates();
3✔
1478
  }
1479

1480
  private void completeCommandlet(CliArguments arguments, Commandlet cmd, CompletionCandidateCollector collector) {
1481

1482
    LOG.trace("Trying to match arguments for auto-completion for commandlet {}", cmd.getName());
5✔
1483
    Iterator<Property<?>> valueIterator = cmd.getValues().iterator();
4✔
1484
    valueIterator.next(); // skip first property since this is the keyword property that already matched to find the commandlet
3✔
1485
    List<Property<?>> properties = cmd.getProperties();
3✔
1486
    // we are creating our own list of options and remove them when matched to avoid duplicate suggestions
1487
    List<Property<?>> optionProperties = new ArrayList<>(properties.size());
6✔
1488
    for (Property<?> property : properties) {
10✔
1489
      if (property.isOption()) {
3✔
1490
        optionProperties.add(property);
4✔
1491
      }
1492
    }
1✔
1493
    CliArgument currentArgument = arguments.current();
3✔
1494
    while (!currentArgument.isEnd()) {
3✔
1495
      LOG.trace("Trying to match argument '{}'", currentArgument);
4✔
1496
      if (currentArgument.isOption() && !arguments.isEndOptions()) {
6!
1497
        if (currentArgument.isCompletion()) {
3✔
1498
          Iterator<Property<?>> optionIterator = optionProperties.iterator();
3✔
1499
          while (optionIterator.hasNext()) {
3✔
1500
            Property<?> option = optionIterator.next();
4✔
1501
            boolean success = option.apply(arguments, this, cmd, collector);
7✔
1502
            if (success) {
2✔
1503
              optionIterator.remove();
2✔
1504
              arguments.next();
3✔
1505
            }
1506
          }
1✔
1507
        } else {
1✔
1508
          Property<?> option = cmd.getOption(currentArgument.get());
5✔
1509
          if (option != null) {
2✔
1510
            arguments.next();
3✔
1511
            boolean removed = optionProperties.remove(option);
4✔
1512
            if (!removed) {
2!
1513
              option = null;
×
1514
            }
1515
          }
1516
          if (option == null) {
2✔
1517
            LOG.trace("No such option was found.");
3✔
1518
            return;
1✔
1519
          }
1520
        }
1✔
1521
      } else {
1522
        if (valueIterator.hasNext()) {
3✔
1523
          Property<?> valueProperty = valueIterator.next();
4✔
1524
          boolean success = valueProperty.apply(arguments, this, cmd, collector);
7✔
1525
          if (!success) {
2✔
1526
            LOG.trace("Completion cannot match any further.");
3✔
1527
            return;
1✔
1528
          }
1529
        } else {
1✔
1530
          LOG.trace("No value left for completion.");
3✔
1531
          return;
1✔
1532
        }
1533
      }
1534
      currentArgument = arguments.current();
4✔
1535
    }
1536
  }
1✔
1537

1538
  /**
1539
   * @param arguments the {@link CliArguments} to apply. Will be {@link CliArguments#next() consumed} as they are matched. Consider passing a
1540
   *     {@link CliArguments#copy() copy} as needed.
1541
   * @param cmd the potential {@link Commandlet} to match.
1542
   * @return the {@link ValidationResult} telling if the {@link CliArguments} can be applied successfully or if validation errors ocurred.
1543
   */
1544
  public ValidationResult apply(CliArguments arguments, Commandlet cmd) {
1545

1546
    LOG.trace("Trying to match arguments to commandlet {}", cmd.getName());
5✔
1547
    CliArgument currentArgument = arguments.current();
3✔
1548
    Iterator<Property<?>> propertyIterator = cmd.getValues().iterator();
4✔
1549
    Property<?> property = null;
2✔
1550
    if (propertyIterator.hasNext()) {
3!
1551
      property = propertyIterator.next();
4✔
1552
    }
1553
    while (!currentArgument.isEnd()) {
3✔
1554
      LOG.trace("Trying to match argument '{}'", currentArgument);
4✔
1555
      Property<?> currentProperty = property;
2✔
1556
      if (!arguments.isEndOptions()) {
3!
1557
        Property<?> option = cmd.getOption(currentArgument.getKey());
5✔
1558
        if (option != null) {
2!
1559
          currentProperty = option;
×
1560
        }
1561
      }
1562
      if (currentProperty == null) {
2!
1563
        LOG.trace("No option or next value found");
×
1564
        ValidationState state = new ValidationState(null);
×
1565
        state.addErrorMessage("No matching property found");
×
1566
        return state;
×
1567
      }
1568
      LOG.trace("Next property candidate to match argument is {}", currentProperty);
4✔
1569
      if (currentProperty == property) {
3!
1570
        if (!property.isMultiValued()) {
3✔
1571
          if (propertyIterator.hasNext()) {
3✔
1572
            property = propertyIterator.next();
5✔
1573
          } else {
1574
            property = null;
2✔
1575
          }
1576
        }
1577
        if ((property != null) && property.isValue() && property.isMultiValued()) {
8!
1578
          arguments.stopSplitShortOptions();
2✔
1579
        }
1580
      }
1581
      boolean matches = currentProperty.apply(arguments, this, cmd, null);
7✔
1582
      if (!matches) {
2!
1583
        ValidationState state = new ValidationState(null);
×
1584
        state.addErrorMessage("No matching property found");
×
1585
        return state;
×
1586
      }
1587
      currentArgument = arguments.current();
3✔
1588
    }
1✔
1589
    return ValidationResultValid.get();
2✔
1590
  }
1591

1592
  @Override
1593
  public Path findBash() {
1594
    if (this.bash != null) {
3✔
1595
      return this.bash;
3✔
1596
    }
1597
    Path bashPath = findBashOnBashPath();
3✔
1598
    if (bashPath == null) {
2✔
1599
      bashPath = findBashInPath();
3✔
1600
      if (bashPath == null && (getSystemInfo().isWindows() || SystemInfoImpl.INSTANCE.isWindows())) {
6!
1601
        bashPath = findBashOnWindowsDefaultGitPath();
3✔
1602
        if (bashPath == null) {
2!
1603
          bashPath = findBashInWindowsRegistry();
3✔
1604
        }
1605
      }
1606
    }
1607
    if (bashPath == null) {
2✔
1608
      LOG.error("No bash executable could be found on your system.");
4✔
1609
    } else {
1610
      this.bash = bashPath;
3✔
1611
    }
1612
    return bashPath;
2✔
1613
  }
1614

1615
  private Path findBashOnBashPath() {
1616
    LOG.trace("Trying to find BASH_PATH environment variable.");
3✔
1617
    Path bash;
1618
    String bashPathVariableName = IdeVariables.BASH_PATH.getName();
3✔
1619
    String bashVariable = getVariables().get(bashPathVariableName);
5✔
1620
    if (bashVariable != null) {
2✔
1621
      bash = Path.of(bashVariable);
5✔
1622
      if (Files.exists(bash)) {
5✔
1623
        LOG.debug("{} environment variable was found and points to: {}", bashPathVariableName, bash);
5✔
1624
        return bash;
2✔
1625
      } else {
1626
        LOG.error("The environment variable {} points to a non existing file: {}", bashPathVariableName, bash);
5✔
1627
        return null;
2✔
1628
      }
1629
    } else {
1630
      LOG.debug("{} environment variable was not found", bashPathVariableName);
4✔
1631
      return null;
2✔
1632
    }
1633
  }
1634

1635
  /**
1636
   * @param path the path to check.
1637
   * @param toIgnore the String sequence which needs to be checked and ignored.
1638
   * @return {@code true} if the sequence to ignore was not found, {@code false} if the path contained the sequence to ignore.
1639
   */
1640
  private boolean checkPathToIgnoreLowercase(Path path, String toIgnore) {
1641
    String s = path.toAbsolutePath().toString().toLowerCase(Locale.ROOT);
6✔
1642
    return !s.contains(toIgnore);
7!
1643
  }
1644

1645
  /**
1646
   * Tries to find the bash.exe within the PATH environment variable.
1647
   *
1648
   * @return Path to bash.exe if found in PATH environment variable, {@code null} if bash.exe was not found.
1649
   */
1650
  private Path findBashInPath() {
1651
    LOG.trace("Trying to find bash in PATH environment variable.");
3✔
1652
    Path bash;
1653
    String pathVariableName = IdeVariables.PATH.getName();
3✔
1654
    if (pathVariableName != null) {
2!
1655
      Path plainBash = Path.of(BASH);
5✔
1656
      Predicate<Path> pathsToIgnore = p -> checkPathToIgnoreLowercase(p, "\\appdata\\local\\microsoft\\windowsapps") && checkPathToIgnoreLowercase(p,
16!
1657
          "\\windows\\system32");
1658
      Path bashPath = getPath().findBinary(plainBash, pathsToIgnore);
6✔
1659
      bash = bashPath.toAbsolutePath();
3✔
1660
      if (bashPath.equals(plainBash)) {
4✔
1661
        LOG.warn("No usable bash executable was found in your PATH environment variable!");
3✔
1662
        bash = null;
3✔
1663
      } else {
1664
        if (Files.exists(bashPath)) {
5!
1665
          LOG.debug("A proper bash executable was found in your PATH environment variable at: {}", bash);
5✔
1666
        } else {
1667
          bash = null;
×
1668
          LOG.error("A path to a bash executable was found in your PATH environment variable at: {} but the file is not existing.", bash);
×
1669
        }
1670
      }
1671
    } else {
1✔
1672
      bash = null;
×
1673
      // this should never happen...
1674
      LOG.error("PATH environment variable was not found");
×
1675
    }
1676
    return bash;
2✔
1677
  }
1678

1679
  /**
1680
   * Tries to find the bash.exe within the Windows registry.
1681
   *
1682
   * @return Path to bash.exe if found in registry, {@code null} if bash.exe was found.
1683
   */
1684
  protected Path findBashInWindowsRegistry() {
1685
    LOG.trace("Trying to find bash in Windows registry");
×
1686
    // If not found in the default location, try the registry query
1687
    String[] bashVariants = { "GitForWindows", "Cygwin\\setup" };
×
1688
    String[] registryKeys = { "HKEY_LOCAL_MACHINE", "HKEY_CURRENT_USER" };
×
1689
    for (String bashVariant : bashVariants) {
×
1690
      LOG.trace("Trying to find bash variant: {}", bashVariant);
×
1691
      for (String registryKey : registryKeys) {
×
1692
        LOG.trace("Trying to find bash from registry key: {}", registryKey);
×
1693
        String toolValueName = ("GitForWindows".equals(bashVariant)) ? "InstallPath" : "rootdir";
×
1694
        String registryPath = registryKey + "\\Software\\" + bashVariant;
×
1695

1696
        String path = getWindowsHelper().getRegistryValue(registryPath, toolValueName);
×
1697
        if (path != null) {
×
1698
          Path bashPath = Path.of(path + "\\bin\\bash.exe");
×
1699
          if (Files.exists(bashPath)) {
×
1700
            LOG.debug("Found bash at: {}", bashPath);
×
1701
            return bashPath;
×
1702
          } else {
1703
            LOG.error("Found bash at: {} but it is not pointing to an existing file", bashPath);
×
1704
            return null;
×
1705
          }
1706
        } else {
1707
          LOG.info("No bash executable could be found in the Windows registry.");
×
1708
        }
1709
      }
1710
    }
1711
    // no bash found
1712
    return null;
×
1713
  }
1714

1715
  private Path findBashOnWindowsDefaultGitPath() {
1716
    // Check if Git Bash exists in the default location
1717
    LOG.trace("Trying to find bash on the Windows default git path.");
3✔
1718
    Path defaultPath = Path.of(getDefaultWindowsGitPath());
6✔
1719
    if (!defaultPath.toString().isEmpty() && Files.exists(defaultPath)) {
4!
1720
      LOG.trace("Found default path to git bash on Windows at: {}", getDefaultWindowsGitPath());
×
1721
      return defaultPath;
×
1722
    }
1723
    LOG.debug("No bash was found on the Windows default git path.");
3✔
1724
    return null;
2✔
1725
  }
1726

1727
  @Override
1728
  public WindowsPathSyntax getPathSyntax() {
1729

1730
    return this.pathSyntax;
3✔
1731
  }
1732

1733
  /**
1734
   * @param pathSyntax new value of {@link #getPathSyntax()}.
1735
   */
1736
  public void setPathSyntax(WindowsPathSyntax pathSyntax) {
1737

1738
    this.pathSyntax = pathSyntax;
3✔
1739
  }
1✔
1740

1741
  /**
1742
   * @return the {@link IdeStartContextImpl}.
1743
   */
1744
  public IdeStartContextImpl getStartContext() {
1745

1746
    return startContext;
3✔
1747
  }
1748

1749
  /**
1750
   * @return the {@link WindowsHelper}.
1751
   */
1752
  public final WindowsHelper getWindowsHelper() {
1753

1754
    if (this.windowsHelper == null) {
3✔
1755
      this.windowsHelper = createWindowsHelper();
4✔
1756
    }
1757
    return this.windowsHelper;
3✔
1758
  }
1759

1760
  /**
1761
   * @return the new {@link WindowsHelper} instance.
1762
   */
1763
  protected WindowsHelper createWindowsHelper() {
1764

1765
    return new WindowsHelperImpl(this);
×
1766
  }
1767

1768
  /**
1769
   * Reloads this context and re-initializes the {@link #getVariables() variables}.
1770
   */
1771
  public void reload() {
1772

1773
    this.variables = null;
3✔
1774
    this.customToolRepository = null;
3✔
1775
  }
1✔
1776

1777
  @Override
1778
  public void writeVersionFile(VersionIdentifier version, Path installationPath) {
1779

1780
    assert (Files.isDirectory(installationPath));
6!
1781
    Path versionFile = installationPath.resolve(FILE_SOFTWARE_VERSION);
4✔
1782
    getFileAccess().writeFileContent(version.toString(), versionFile);
6✔
1783
  }
1✔
1784

1785
  /*
1786
   * @param home the IDE_HOME directory.
1787
   * @param workspace the name of the active workspace folder.
1788
   */
1789
  protected static record IdeHomeAndWorkspace(Path home, String workspace) {
9✔
1790

1791
  }
1792

1793
  /**
1794
   * Returns the default git path on Windows. Required to be overwritten in tests.
1795
   *
1796
   * @return default path to git on Windows.
1797
   */
1798
  public String getDefaultWindowsGitPath() {
1799
    return DEFAULT_WINDOWS_GIT_PATH;
×
1800
  }
1801

1802
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc