changeset 498:0205a5c4566b

Issue 38: preserve user formatting and comments when updating configuration files
author Artem Tikhomirov <tikhomirov.artem@gmail.com>
date Fri, 26 Oct 2012 18:17:15 +0200
parents 02140be396d5
children 899a1b68ef03
files src/org/tmatesoft/hg/core/HgIOException.java src/org/tmatesoft/hg/core/HgMissingConfigElementException.java src/org/tmatesoft/hg/core/HgUpdateConfigCommand.java src/org/tmatesoft/hg/internal/ConfigFile.java src/org/tmatesoft/hg/internal/ConfigFileParser.java
diffstat 5 files changed, 322 insertions(+), 89 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/core/HgIOException.java	Fri Oct 26 18:17:15 2012 +0200
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2012 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.core;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Tailored wrap for {@link IOException} and similar I/O-related issues. Unlike {@link IOException}, 
+ * keeps track of {@link File} that caused the problem. Besides, additional information (like revision, 
+ * see {@link HgException}) may be attached.
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+@SuppressWarnings("serial")
+public class HgIOException extends HgException {
+	private final File file;
+
+	public HgIOException(String message, File troubleFile) {
+		this(message, null, troubleFile);
+	}
+
+	/**
+	 * @param message describes the issue, never <code>null</code>
+	 * @param cause root cause for the error, likely {@link IOException} or its subclass, but not necessarily, and may be omitted. 
+	 * @param troubleFile file we tried to deal with, never <code>null</code>
+	 */
+	public HgIOException(String message, Exception cause, File troubleFile) {
+		super(message, cause);
+		file = troubleFile;
+	}
+
+	public File getFile() {
+		return file;
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/org/tmatesoft/hg/core/HgMissingConfigElementException.java	Fri Oct 26 18:17:15 2012 +0200
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2012 TMate Software Ltd
+ *  
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; version 2 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * For information on how to redistribute this software under
+ * the terms of a license other than GNU General Public License
+ * contact TMate Software at support@hg4j.com
+ */
+package org.tmatesoft.hg.core;
+
+/**
+ * Exception 
+ * 
+ * @author Artem Tikhomirov
+ * @author TMate Software Ltd.
+ */
+@SuppressWarnings("serial")
+public class HgMissingConfigElementException extends HgException {
+
+	private final String section;
+	private final String key;
+
+	public HgMissingConfigElementException(String message, String troubleSection, String missingKey) {
+		super(message);
+		section = troubleSection;
+		key = missingKey;
+	}
+
+	public String getSection() {
+		return section;
+	}
+	
+	public String getKey() {
+		return key;
+	}
+}
--- a/src/org/tmatesoft/hg/core/HgUpdateConfigCommand.java	Thu Oct 25 19:59:08 2012 +0200
+++ b/src/org/tmatesoft/hg/core/HgUpdateConfigCommand.java	Fri Oct 26 18:17:15 2012 +0200
@@ -17,33 +17,33 @@
 package org.tmatesoft.hg.core;
 
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 
 import org.tmatesoft.hg.internal.ConfigFile;
-import org.tmatesoft.hg.internal.Experimental;
+import org.tmatesoft.hg.internal.ConfigFileParser;
 import org.tmatesoft.hg.internal.Internals;
 import org.tmatesoft.hg.repo.HgInternals;
+import org.tmatesoft.hg.repo.HgInvalidStateException;
 import org.tmatesoft.hg.repo.HgRepository;
 
 /**
- * WORK IN PROGRESS, DO NOT USE
+ * Command to alter Mercurial configuration settings at various levels (system-wide, user-wide, repository-wide).
  *
  * @author Artem Tikhomirov
  * @author TMate Software Ltd.
  */
-@Experimental(reason="Investigating approaches to alter Hg configuration files")
 public final class HgUpdateConfigCommand extends HgAbstractCommand<HgUpdateConfigCommand> {
 	
+	private final SessionContext sessionCtx;
 	private final File configFile;
 	
-	private Map<String,List<String>> toRemove;
-	private Map<String,Map<String,String>> toSet;
-
-	private final SessionContext sessionCtx;
+	private final List<Operation> changes = new LinkedList<Operation>();
+	private boolean ignoreMissingKeys = false;
 
 	private HgUpdateConfigCommand(SessionContext sessionContext, File configurationFile) {
 		sessionCtx = sessionContext;
@@ -51,7 +51,6 @@
 	}
 	
 	public static HgUpdateConfigCommand forRepository(HgRepository hgRepo) {
-		// XXX HgRepository to implement SessionContext.Provider (with getContext())?
 		return new HgUpdateConfigCommand(hgRepo.getSessionContext(), HgInternals.getImplementationRepo(hgRepo).getFileFromRepoDir("hgrc"));
 	}
 	
@@ -62,94 +61,230 @@
 	public static HgUpdateConfigCommand forInstallation(SessionContext ctx) {
 		return new HgUpdateConfigCommand(ctx, Internals.getInstallationConfigurationFileToWrite(ctx));
 	}
-	
+
 	/**
-	 * Remove a property altogether
+	 * Remove an entry altogether. If no entry with the key found, {@link #execute()} fails.
+	 * 
+	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
+	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
 	 * @return <code>this</code> for convenience
+	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
 	 */
-	public HgUpdateConfigCommand remove(String section, String key) {
-		if (toRemove == null) {
-			toRemove = new LinkedHashMap<String, List<String>>();
-		}
-		List<String> s = toRemove.get(section);
-		if (s == null) {
-			toRemove.put(section, s = new ArrayList<String>(5));
-		}
-		s.add(key);
-		if (toSet != null && toSet.containsKey(section)) {
-			toSet.get(section).remove(key);
-		}
+	public HgUpdateConfigCommand remove(String section, String key) throws IllegalArgumentException {
+		checkSection(section);
+		checkKey(key);
+		changes.add(Operation.deleteEntry(section, key));
 		return this;
 	}
 
 	/**
-	 * Delete single attribute in a multi-valued property
-	 * @return <code>this</code> for convenience
-	 */
-	public HgUpdateConfigCommand remove(String section, String key, String value) {
-		throw new UnsupportedOperationException();
-	}
-	
-	/**
-	 * Set single-valued properties or update multi-valued with a single value
+	 * Delete single attribute in a multi-valued property. If specified value not found among values of
+	 * the identified entry, {@link #execute()} fails. 
+	 * 
+	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
+	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
+	 * @param value one of the values to remove, not <code>null</code> or an empty value
 	 * @return <code>this</code> for convenience
+	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
 	 */
-	public HgUpdateConfigCommand put(String section, String key, String value) {
-		if (toSet == null) {
-			toSet =  new LinkedHashMap<String, Map<String,String>>();
-		}
-		Map<String,String> s = toSet.get(section);
-		if (s == null) {
-			toSet.put(section, s = new LinkedHashMap<String, String>());
-		}
-		s.put(key, value);
+	public HgUpdateConfigCommand remove(String section, String key, String value) throws IllegalArgumentException {
+		checkSection(section);
+		checkKey(key);
+		changes.add(Operation.deleteValue(section, key, value));
 		return this;
 	}
 	
 	/**
-	 * Multi-valued properties
+	 * Set single-valued property or update multi-valued with a single value
+	 * 
+	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
+	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
+	 * @param value new value, may be <code>null</code>
 	 * @return <code>this</code> for convenience
+	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
 	 */
-	public HgUpdateConfigCommand add(String section, String key, String value) {
-		throw new UnsupportedOperationException();
+	public HgUpdateConfigCommand put(String section, String key, String value) throws IllegalArgumentException {
+		checkSection(section);
+		checkKey(key);
+		changes.add(Operation.setValue(section, key, value));
+		return this;
 	}
 	
 	/**
-	 * Perform config file update
+	 * Add value to a multi-valued entry. If specified entry not found, {@link #execute()} fails.
 	 * 
+	 * @param section identifies section to alter, not <code>null</code> or otherwise ill-formed name
+	 * @param key identifies entry within section, not <code>null</code> or otherwise ill-formed name 
+	 * @param value new value to add, not <code>null</code> or an empty value
+	 * @return <code>this</code> for convenience
+	 * @throws IllegalArgumentException if arguments are <code>null</code> or empty
+	 */
+	public HgUpdateConfigCommand add(String section, String key, String value) throws IllegalArgumentException {
+		checkSection(section);
+		checkKey(key);
+		changes.add(Operation.addValue(section, key, value));
+		return this;
+	}
+	
+	/**
+	 * Tells whether {@link #execute()} shall fail with exception if keys selected for modification were not found. 
+	 * If <code>true</code>, missing keys would be silently ignored. 
+	 * When <code>false</code>(<em>default</em>), exception would be raised.
+	 *  
+	 * @param ignoreMissing pass <code>true</code> to ignore any incorrect keys
+	 * @return <code>this</code> for convenience
+	 */
+	public HgUpdateConfigCommand ignoreMissing(boolean ignoreMissing) {
+		ignoreMissingKeys = ignoreMissing;
+		return this;
+	}
+	
+	/**
+	 * Perform configuration file update.
+	 * 
+	 * @throws HgMissingConfigElementException if attempt to alter an entry failed to find one, and missing keys are not ignored
+	 * @throws HgIOException when configuration file read/write attemt has failed
 	 * @throws HgException subclass thereof to indicate specific issue with the command arguments or repository state
 	 */
-	public void execute() throws HgException {
+	public void execute() throws HgMissingConfigElementException, HgIOException, HgException {
 		try {
-			ConfigFile cfg = new ConfigFile(sessionCtx);
-			cfg.addLocation(configFile);
-			if (toRemove != null) {
-				for (Map.Entry<String,List<String>> s : toRemove.entrySet()) {
-					for (String e : s.getValue()) {
-						cfg.putString(s.getKey(), e, null);
+			ConfigFile cfgRead = new ConfigFile(sessionCtx);
+			cfgRead.addLocation(configFile);
+			ConfigFileParser cfgWrite = new ConfigFileParser();
+			FileInputStream fis = new FileInputStream(configFile);
+			cfgWrite.parse(fis);
+			fis.close();
+			for (Operation op : changes) {
+				if (!ignoreMissingKeys && !cfgRead.hasSection(op.section)) {
+					throw new HgMissingConfigElementException("Bad section name", op.section, op.key);
+				}
+				Map<String, String> sect = cfgRead.getSection(op.section);
+				if (!ignoreMissingKeys && !sect.containsKey(op.key)) {
+					throw new HgMissingConfigElementException("Bad key name", op.section, op.key);
+				}
+				String oldValue = sect.get(op.key);
+				if (oldValue == null) {
+					oldValue = "";
+				}
+				switch (op.kind) {
+				case AddValue: {
+					String separator = ", "; // XXX shall parse and find out separator kind in use
+					String newValue = oldValue + separator + op.value;
+					if (sect.containsKey(op.key)) {
+						cfgWrite.change(op.section, op.key, newValue);
+					} else {
+						cfgWrite.add(op.section, op.key, newValue);
 					}
+					break;
+				}
+				case DelValue: {
+					if (!ignoreMissingKeys && (oldValue.length() == 0 || !oldValue.contains(op.value))) {
+						throw new HgMissingConfigElementException(String.format("Bad value '%s' to delete from '%s'", op.value, oldValue), op.section, op.key);
+					}
+					int start = oldValue.indexOf(op.value);
+					if (start == -1) {
+						// nothing to change
+						break;
+					}
+					int commaPos = -1;
+					for (int i = start-1; i >=0; i--) {
+						if (oldValue.charAt(i) == ',') {
+							commaPos = i;
+							break;
+						}
+					}
+					for (int i = start + op.value.length(); commaPos == -1 && i < oldValue.length(); i++) {
+						if (oldValue.charAt(i) == ',') {
+							commaPos = i;
+							break;
+						}
+					}
+					String newValue;
+					if (commaPos >= 0) {
+						if (commaPos < start) {
+							// from preceding comma up to end of value
+							newValue = oldValue.substring(0, commaPos) + oldValue.substring(start + op.value.length());
+						} else {
+							// from value start up to and including subsequent comma
+							newValue = oldValue.substring(0, start) + oldValue.substring(commaPos+1);
+						}
+					} else {
+						// found no separator, just remove the value
+						// extra whitespaces (if space, not a comma is a separator) won't hurt
+						newValue = oldValue.substring(0, start) + oldValue.substring(start + op.value.length());
+					}
+					cfgWrite.change(op.section, op.key, newValue);
+					break;
+				}
+				case SetValue: {
+					if (sect.containsKey(op.key)) {
+						cfgWrite.change(op.section, op.key, op.value);
+					} else {
+						cfgWrite.add(op.section, op.key, op.value);
+					}
+					break;
+				}
+				case DelEntry: {
+					cfgWrite.delete(op.section, op.key);
+					break;
+				}
+				default: throw new HgInvalidStateException(String.format("Unknown change %s", op.kind));
 				}
 			}
-			if (toSet != null) {
-				for (Map.Entry<String,Map<String,String>> s : toSet.entrySet()) {
-					for (Map.Entry<String, String> e : s.getValue().entrySet()) {
-						cfg.putString(s.getKey(), e.getKey(), e.getValue());
-					}
-				}
-			}
-			cfg.writeTo(configFile);
+			FileOutputStream fos = new FileOutputStream(configFile);
+			cfgWrite.update(fos);
+			fos.close();
 		} catch (IOException ex) {
 			String m = String.format("Failed to update configuration file %s", configFile);
 			throw new HgBadArgumentException(m, ex); // TODO [post-1.0] better exception, it's not bad argument case
 		}
 	}
+	
+	private static void checkSection(String section) throws IllegalArgumentException {
+		if (section == null || section.trim().length() == 0) {
+			throw new IllegalArgumentException(String.format("Section name can't be empty: %s", section));
+		}
+	}
+	
+	private static void checkKey(String key) throws IllegalArgumentException {
+		if (key == null || key.trim().length() == 0) {
+			throw new IllegalArgumentException(String.format("Entry key can't be empty: %s", key));
+		}
+	}
 
 
-	public static void main(String[] args) throws Exception {
-		HgUpdateConfigCommand cmd = HgUpdateConfigCommand.forUser(null);
-		cmd.remove("test1", "sample1");
-		cmd.put("test2", "sample2", "value2");
-		cmd.put("ui", "user-name", "Another User <email@domain.com>");
-		cmd.execute();
+	private static class Operation {
+		private enum OpKind { AddValue, SetValue, DelValue, DelEntry };
+
+		public final OpKind kind;
+		public final String section;
+		public final String key;
+		public final String value;
+		
+		private Operation(OpKind t, String s, String k, String v) {
+			kind = t;
+			section = s;
+			key = k;
+			value = v;
+		}
+		
+		public static Operation deleteEntry(String section, String key) throws IllegalArgumentException {
+			return new Operation(OpKind.DelEntry, section, key, null);
+		}
+		public static Operation deleteValue(String section, String key, String value) throws IllegalArgumentException {
+			if (value == null || value.trim().length() == 0) {
+				throw new IllegalArgumentException(String.format("Can't remove empty value '%s'", value));
+			}
+			return new Operation(OpKind.DelValue, section, key, value);
+		}
+		public static Operation addValue(String section, String key, String value) throws IllegalArgumentException {
+			if (value == null || value.trim().length() == 0) {
+				throw new IllegalArgumentException(String.format("Can't add empty value '%s'", value));
+			}
+			return new Operation(OpKind.AddValue, section, key, value);
+		}
+		public static Operation setValue(String section, String key, String value) throws IllegalArgumentException {
+			return new Operation(OpKind.SetValue, section, key, value);
+		}
 	}
 }
--- a/src/org/tmatesoft/hg/internal/ConfigFile.java	Thu Oct 25 19:59:08 2012 +0200
+++ b/src/org/tmatesoft/hg/internal/ConfigFile.java	Fri Oct 26 18:17:15 2012 +0200
@@ -62,6 +62,11 @@
 		return sections == null ? Collections.<String>emptyList() : Collections.unmodifiableList(sections);
 	}
 
+	/**
+	 * Access map of section keys and values.
+	 * @param sectionName name of the section to retrieve
+	 * @return never <code>null</code>, empty map in case no section with specified name found 
+	 */
 	public Map<String,String> getSection(String sectionName) {
 		if (sections == null) {
 			return Collections.emptyMap();
--- a/src/org/tmatesoft/hg/internal/ConfigFileParser.java	Thu Oct 25 19:59:08 2012 +0200
+++ b/src/org/tmatesoft/hg/internal/ConfigFileParser.java	Fri Oct 26 18:17:15 2012 +0200
@@ -16,7 +16,6 @@
  */
 package org.tmatesoft.hg.internal;
 
-import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -209,7 +208,27 @@
 			// if section comes more than once, update only first one.
 			processedSections.add(section.name);
 		}
+		// push rest of the contents
 		out.write(contents, contentsOffset, contents.length - contentsOffset);
+		//
+		// add entries in new sections
+		LinkedHashSet<String> newSections = new LinkedHashSet<String>();
+		for (Iterator<String> it = additions.iterator(); it.hasNext();) {
+			String s = it.next(); it.next(); it.next();
+			if (!processedSections.contains(s)) {
+				newSections.add(s);
+			}
+		}
+		for (String newSectionName : newSections) {
+			out.write(String.format("\n[%s]", newSectionName).getBytes());
+			for (Iterator<String> it = additions.iterator(); it.hasNext();) {
+				String s = it.next(), k = it.next(), v = it.next();
+				if (newSectionName.equals(s)) {
+					out.write(String.format("\n%s = %s", k, v).getBytes());
+				}
+			}
+			out.write("\n".getBytes());
+		}
 	}
 	
 	private void processLine(int lineNumber, int offset, byte[] line) throws IOException {
@@ -359,25 +378,4 @@
 			e.toArray(entries);
 		}
 	}
-
-	public static void main(String[] args) throws Exception {
-		ConfigFileParser p = new ConfigFileParser();
-		p.parse(new ByteArrayInputStream(xx.getBytes()));
-		System.out.println(">>>");
-		System.out.println(xx);
-		System.out.println("===");
-		p.add("sect1", "key5", "x");
-		ByteArrayOutputStream out = new ByteArrayOutputStream(xx.length());
-		p.update(out);
-		System.out.println(new String(out.toByteArray()));
-		/*
-		for (Section s : p.sections) {
-			System.out.printf("[%s@%d]\n", s.name, s.start);
-			for (Entry e : s.entries) {
-				System.out.printf("%s@%d = %d..%d\n", e.name, e.start, e.valueStart, e.valueEnd);
-			}
-		}
-		*/
-	}
-	private static final String xx = "#comment1\n [sect1]\nkey = value #not a comment2\n#comment3\nkey2=   \nkey3 =  \n  value1, #cc\n  value2\nkey4 = v1,\n  v2 \n  ,v3\n\n\n[sect2]\nx = a";
 }